graf

7. 후킹1 - 펌웨어 변조 본문

RSW-725R/분석 자료 (공개용)

7. 후킹1 - 펌웨어 변조

graf 2026. 4. 22. 10:18

 

리버싱 실패 후 쓰러져있던 차에 떠오른 방법이다. 

변조된 펌웨어를 올려서 로드가 끝난 개인키를 uart로 보내버릴 수는 없을까? 

 

만약 성공한다면 복잡하게 복호화 동작을 분석할 필요도 없이 
개발자가 만들어놓은 로직을 그대로 갖다 쓸 수 있을 것이다. 

 

 

1. 가능성 확인

어쩌면 충분히 해볼만 할지도 모르겠다는 생각이 든다. 
그래도 먼저 몇가지 확인해야할 것들이 있다. 

 

1.1 펌웨어 업로드 방법

일단 새로 만든 펌웨어를 장치에 업로드할 수 있어야한다. 

 

python3 -m esptool
esptool.py v4.9.0
positional arguments:
  {load_ram,dump_mem,read_mem,write_mem,write_flash,run,image_info,make_image,elf2image,read_mac,chip_id,flash_id,read_flash_status,write_flash_status,read_flash,verify_flash,erase_flash,erase_region,read_flash_sfdp,merge_bin,get_security_info,version}
                        Run esptool.py {command} -h for additional help
    read_mem            Read arbitrary memory location
    write_mem           Read-modify-write to arbitrary memory location
    write_flash         Write a binary blob to flash
    read_flash          Read SPI flash content

 

추출할 때 read_flash를 썼던 것처럼 
esptool에서 write_flash 명령어로 업로드할 수 있다. 


업로드 자체는 그리 어렵지 않을 거다. 

디버그 인터페이스나 펌웨어 추출 기능이 그대로 남아있는걸 보면 
업로드 자체를 막아놨을 가능성은 그리 크지 않을 거라고 생각한다. 

 

 

1.2 디버깅 메시지 출력

부팅 메시지는 확인해봤지만 펌웨어에 들어있던 디버깅 메시지들의 출력은 굳이 확인해보려 하지 않았다. 

만약 이 출력이 uart로 나오는 거라면 결과를 확인할 수 있을 것이다. 

 

장치를 서버와 다시 연결해봤지만 디버깅 메시지는 어떤 것도 나오지 않았다. 
그런데 생각해보니 쓰루홀 중에 용도를 알 수 없는 게 하나 있지 않았나? 

 

 

7홀 4홀
EN  
GPIO0  
RXD  
TXD GND
GPIO2 SoC-31
RST SoC-29
GND EN

 

예전에 정리했던 쓰루홀 정보다. 
여기서 GPIO2의 용도를 확인하지 못했는데 혹시 여기서 나오고 있을지도 모른다. 

 

 

데이터 시트를 확인해보면 uart tx로 나온다. 

 

 

해당 홀도 연결 후 다시 부팅 메시지를 확인했다. 

데이터가 깨져있긴 하지만 수상할 정도로 뭔가 많이 나온다. 

 

부팅 메시지 출력과 디버깅 메시지 출력의 보드레이트가 다를 수 있다. 
다른 속도로도 하나씩 시도해보자. 

 

 

115200에서 나왔다. 
둘의 보드레이트가 달랐던게 정답이었다. 

 

 

private key loaded
Success to open ca
ca loaded

펌웨어 분석 과정에서 확인했던 디버깅 메시지들이 전부 나온다. 

 

 

wifi 연결 상태
할당된 ip

 

서버와 주고 받은 on/off 명령들까지 전부 나온다. 

 

리버싱을 시도하며 확인했던 정보대로면 
디버깅 메시지는 0x4020D3A4 함수를 통해 출력된다. 
하게 된다면 나도 이걸 키 추출에 그대로 써야겠다. 

 

 

1.3 펌웨어 무결성 검증

변조된 펌웨어를 올리지 못하도록하는 무결성 검증을 위한 조치들이 있을 수 있다. 

 

 ets Jan  8 2013,rst cause:1, boot mode:(3,7)

load 0x4010f000, len 1392, room 16 
tail 0
chksum 0xd0
csum 0xd0
v3d128e5c
~ld

 

일단 부팅 메시지에서는 체크섬 말고 특별히 나오는건 없다. 
체크섬이 0xd0라고 나온다. 

 

load 주소가 0x4010f000인 데이터라면, 

d0는 첫번째 이미지 맨 뒤에 있는 이걸 말하는 것 같다. 

 

근데 저거 하나만 검증한다고? 
세그먼트가 다섯개는 더 있는데? 

 

그런데 관련 자료를 찾아봐도 체크섬 외에 무결성 검증을 위한 다른 조치들은 특별히 나오지 않는다. 

 

 

Firmware Image Format - ESP8266

그나마 나온게 이거 하나인데 
0으로 패딩을 넣고 모든 바이트로 xor을 해서 
맨 마지막 바이트에 체크섬을 넣는다는 내용이다. 

 

위 자료가 맞다면 세그먼트마다 체크섬이 있지는 않고 
이미지 헤더 별로 하나의 체크섬만 존재할 것이다. 

그렇다면 이전에 찾았던 두번째 이미지 헤더 쪽에도 0 패딩과 체크섬이 있어야한다. 

 

 

두번째 이미지의 마지막 세그먼트 이후의 데이터다. 
00이 들어오고 마지막 바이트만 0x78인걸 보면 이것도 체크섬일 가능성이 크다. 

 

 

def checksum(filename, offset, length):
    result = 0xef
    with open(filename, "rb") as f:
        f.seek(offset)
        for _ in range(length):
            byte = f.read(1)
            if not byte:
                break
            result ^= byte[0]
    return result

filename = "esp_backup.bin"
offset = 0x10
length = 0x570

res = checksum(filename, offset, length)
print(f"XOR result: 0x{res:02X}")

 

첫번째 이미지에서 체크섬을 계산해봤다. 
계산 대상에서 헤더는 제외해야한다. 

 

결과는 동일하게 나왔다. 

 

 

def checksum(filename, offset, length):
    result = 0
    with open(filename, "rb") as f:
        f.seek(offset)
        for _ in range(length):
            byte = f.read(1)
            if not byte:
                break
            result ^= byte[0]
    return result

filename = "esp_backup.bin"

res = 0xef
res ^= checksum(filename, 0x1010, 0x68818)
res ^= checksum(filename, 0x69830, 0xe8)
res ^= checksum(filename, 0x69920, 0x6d08)
res ^= checksum(filename, 0x70630, 0x568)
res ^= checksum(filename, 0x70ba0, 0xc3c)
print(f"XOR result: 0x{res:02X}")

 

두번째 이미지도 계산해봤다. 

 

 

이번에도 일치한다. 

 

올바른 체크섬을 구하는 방법을 알았으니 

체크섬 계산에 대해서는 더 걱정하지 않아도 될 것 같다. 

 

 

1.4 사용되지 않는 빈 영역 찾기

내가 만든 코드를 올리고싶지만 각 영역들이 구체적으로 
어떤 용도로 사용되는지 알 수 없으니 함부로 건들 수가 없다. 

 

내가 찾는 곳은

1. 데이터로 아무 의미 없는 패딩만 들어있으며 

2. 페이로드를 넣을 충분한 크기를 갖고 있고 

3. 실행 가능한 영역에 속한 부분이다. 

 

가장 만만한 곳은 이 부분이다. 
첫번째 이미지와 두번째 이미지 사이 aa로 채워진 부분

처음 1mb가 xip가 가능한 영역이었으니 여기에 코드를 넣고

플래시 메모리 주소로 직접 접근을 시도해볼 것이다. 


어쩌면 헤더에 등록된 세그먼트만 실행이 가능할 지도 모른다. 
만약 안되면 부트로더 세그먼트의 크기를 확장하고 적재 주소를 기준으로 다시 시도해봐야겠다. 

 

 

2. 펌웨어 변조

예상되는 문제점과 조건은 모두 확인했다. 
분명 해볼만 하다. 

그래도 예상 못한 문제가 생길 수 있으니 하나씩 테스트를 해보자. 

 

2.1 데이터 영역 변경

먼저 가장 단순하게 데이터 영역에서 디버깅 메시지로 출력되는 리터럴을 변조해볼 것이다. 

 

이 테스트에서는 펌웨어 업로드 과정에서 생길 수 있는 문제나 
체크섬 이외에 다른 검증 절차가 있는지를 확인할 수 있을 것이다. 

 

2.1.1 펌웨어 수정

private key loaded
이 문자열을 먼저 바꿔볼거다. 
load의 l을 r로 바꿔보자. 

 

소심해 보이겠지만 어쩔 수 없다. 
코드가 동작을 안하면 디버깅할 방법이 없다.. 

 

 

0x6c -> 0x72
해당 문자의 오프셋은 0x70f8e

 

cp ./esp_backup.bin ./test1.bin
printf '\x72' | dd of=test1.bin bs=1 seek=$((0x70f8e)) count=1 conv=notrunc
hexdump -C ./test1.bin > res.txt

 

우측을 보면 loaded가 roaded로 변경됐다. 

 

 

2.1.2 체크섬 수정

체크섬은 0x66으로 바꾸면 된다. 

 

 

현재 체크섬 0x78
오프셋 0x717df

 

printf '\x66' | dd of=test1.bin bs=1 seek=$((0x717df)) count=1 conv=notrunc
hexdump -C ./test1.bin > res.txt

 

변경됐다. 이제 이걸 업로드해보자

 

 

2.1.3 펌웨어 업로드

선이 지저분해서 사진이 의미가 있을지 모르겠지만 
일단 기록용으로 찍었다. 

 

 

GPIO0
RXD    
TXD
GPIO2

 

일단 핀은 이렇게 연결했다. 

라즈베리파이로 전원 공급 중이고 
GPIO0은 GND에 연결해서 부트 모드를 uart download 모드로 바꿨다. 
그리고 지난번처럼 pl2303 사이에 로직분석기도 껴놨다. 

 

 

모드도 정상적으로 바뀌었고 연결도 잘 됐다. 

 

 

python -m esptool --port COM3 --baud 74880 write_flash 0x00000 test1.bin

 

 

>python -m esptool --port COM3 --baud 74880 write_flash 0x00000 test1.bin
esptool.py v4.9.0
Serial port COM3
Connecting.........
Detecting chip type... Unsupported detection protocol, switching and trying again...
Connecting...
Detecting chip type... ESP8266
Chip is ESP8266EX
Features: WiFi
Crystal is 26MHz
MAC: 48:e7:29:5e:69:7c
Uploading stub...
Running stub...
Stub running...
Configuring flash size...
Flash will be erased from 0x00000000 to 0x001fffff...
Compressed 2097152 bytes to 343155...
Wrote 2097152 bytes (343155 compressed) at 0x00000000 in 49.7 seconds (effective 337.3 kbit/s)...
Hash of data verified.

Leaving...
Hard resetting via RTS pin...

 

업로드까지 끝났다. 
다행히 아직까지 별다른 문제는 없었다. 

 

 

2.1.4 변경 내용 확인

다시 기존 부트모드로 확인

 

 

부팅 메시지 출력이 바뀌었다. 

펌웨어 변조 후 업로드까지 정상적으로 됐으니 반쯤 성공한 셈이다. 

 

 

2.2 임의 메시지 출력

이번엔 코드를 넣을 예정인 두 이미지 사이 빈 영역에 임의 리터럴 넣고 출력해볼 예정이다. 

 

데이터 영역에 없는 새 데이터를 참조하게 될 것이다. 

이를 통해 세그먼트에 속하지 않은 영역도 접근이 가능한지 확인할 수 있을거다. 

 

만약 여기서 실패한다면 헤더에 세그먼트를 추가로 등록하거나 
코드를 적재할 적절한 메모리를 찾아야할지도 모른다. 

 

이번엔 체크섬을 포함해서 세가지를 수정해야한다. 

1. 리터럴 추가
2. 참조 테이블 수정
3. 체크섬 수정

 

이번에도 하나씩 해보자. 

 

 

2.2.1 리터럴 추가

aa로 채워져있는 이 영역에 문자열 데이터를 넣을 것이다. 
오프셋은 0x600으로 해야겠다. 

 

cp ./test1.bin ./test2.bin
printf "helloworld\x00" | dd of=test2.bin bs=1 seek=$((0x600)) count=11 conv=notrunc
hexdump -C ./test2.bin > res.txt

들어왔다. 
문자열은 helloworld로 했다. 

 

 

2.2.2 참조 테이블 수정

xtensa는 주소를 로드할 때 절대주소를 넣지 않고 
이렇게 근처에 테이블을 만들어놓고 해당 위치를 참조하는 형태로 동작한다. 

 

그리고 이게 private key loaded 문자열 데이터의 주소다. 

 

오프셋은 0x1fc0다. 
이 주소를 내가 넣은 데이터의 주소로 바꿔보자. 

 

플래시 메모리는 주소가 0x40200000에서 시작한다. 
추가한 문자열의 오프셋이 0x600이었으니 주소로는 0x40200600이다. 

 

printf "\x00\x06\x20\x40" | dd of=test2.bin bs=1 seek=$((0x1fc0)) count=4 conv=notrunc
hexdump -C ./test2.bin > res.txt

변경됐다. 

 

 

2.2.3 체크섬 수정

체크섬은 0x1a다. 

 

printf '\x1a' | dd of=test2.bin bs=1 seek=$((0x717df)) count=1 conv=notrunc
hexdump -C ./test2.bin > res.txt

체크섬도 수정했다. 

 

 

2.2.4 펌웨어 업로드

python -m esptool --port COM3 --baud 74880 write_flash 0x00000 test2.bin

 

방식은 이전과 동일하다. 

 

다행히 이번에도 잘 끝났다. 

 

 

2.2.5 변경 내용 확인

private key loaded 대신에 내가 넣었던 helloworld가 출력됐다. 

 

이를 통해 펌웨어 변조 자체가 가능하다는 것이 확실해졌고 

세그먼트에 등록되지 않은 영역을 참조할 수 있다는 것까지 확인됐다. 

 

다행히 최악은 면했다. 
이제 버퍼 출력 함수 만들고 후킹을 시도해봐야겠다.