1. 개요
해당 포스트는 임베디드 장비 분석을 위해 내장된 펌웨어 덤프 방법론을 소개한다.
실습 시 사용한 장비: HW-234(UART 연결 장비, USB to TTL 컨버터), 암-암 점퍼선
2. 펌웨어 란?
펌웨어는 하드웨어를 구동하기 위한 메모리에 내장된 소프트웨어를 말한다. 보통 플래시 메모리에 저장되며, 내장된 소프트웨어 파일시스템은 SquashFS, CramFS, JFFS2 등이 있다.
펌웨어는 하드웨어 초기화 및 커널 로드하는 부트로더, 운영체제 역할하는 커널, 실질적 소프트웨어 역할하는 루트 파일시스템 으로 영역이 구분돼 있다.
3. 펌웨어 덤프
펌웨어 덤프 방법은 다양하다. 플래시 메모리 덤프, SPI를 이용한 덤프, 공식 페이지 내 펌웨어 다운로드, 네트워크 통신 중간자 공격을 통한 펌웨어 탈취, 부트로더 수정을 통한 쉘 획득 후 덤프 등 다양한 방법이 있다. 이번 포스트에선 SPI를 이용한 덤프, 플래시 메모리 덤프, 부트로더 수정을 통한 쉘 획득 후 덤프 을 다룬다.
3.1. SPI 플래시 메모리 덤프
SPI가 무엇인지 부터 알아보자. SPI는 Serial Peripheral Interface로 번역하면 직렬 주변기기 인터페이스다. 즉, 해당 인터페이스는 주 장치(Master)와 종속 장치(Slave) 간 1:1 또는 1:N 통신을 위한 동기식 직렬 데이터 인터페이스로 정의할 수 있다.
SPI 표준 핀은 4개며, 실제 하드웨어(SPI를 적용한 플래시 메모리 이하 SPI 플래시 메모리)에선 전원과 부가 기능을 추가해 8개의 핀을 사용한다.
예시를 보자. 해당 SPI 플래시 메모리 모델명은 W25N01GVZEIG 다. 빨간색 테두리 내 납땜된 부분은 각 핀이 위치해 있으면 총 8개의 핀이다.

공식 사이트 가이드에 위치한 데이터 시트를 보면 각 핀별 역할이 나와있다. 이 중 CS(Chip Select, 칩 선택), CLK(Clock, 동기화), DI(Data In, 데이터 입력), DO(Data Out, 데이터 출력)이 SPI 인터페이스 표준에 사용하는 핀이고 나머지 VCC(전원 인가), WP(쓰기 보호), GND(접지), HOLD(통신 중지)는 확장 핀이다. 플래시 덤프엔 HOLD, WP 핀을 제외한 나머지 핀들이 덤프에 사용된다.

실제 메모리 핀 위치를 알기 위해선 2가지 방법이 있다. 첫번째: 통전테스트, 두번째: O이 위치한 1번 핀 기준 상대적 핀 위치 확인. 해당 포스트에선 두번째 방법으로 핀 위치 식별할 것이다.

하드웨어 메모리를 보면 동그란 홈이 있다. 해당 위치가 1번이다.

3.2. U-Boot md.b 명령어를 이용한 덤프
해당 덤프 방법은 UART 연결이 필요하다. 연결 방법은 https://blog.patchbay.co.kr/posts/hardware_hacking/2026-06-16-uart_iptimes/ 참고바란다.
U-Boot(Universal Bootloader)는 부트로더 일종으로 오픈소스다. 주로 임베디드 장비에서 볼 수 있다.
부트로더 역할은 하드웨어 컴포넌트 초기화 및 로딩, 무결성 체크, OS 로딩, 펌웨어 업데이트 등이 있다. 부트로더도 쉘이 존재하며, 해당 쉘에서 여러 파일시스템 마운트나 조작이 가능하다. U-Boot 경우 부트로더 진입 시 U-Boot Console 메뉴가 존재하고 해당 콘솔에서 메모리 읽기, 변조, 삭제 등 여러 기능 수행이 가능하다.
0번을 눌러 U-Boot 콘솔 진입 시 내장된 여러 명령어 실행 가능하다.


printenv 명령을 통해 저장된 환경변수를 보자. 램에 이미지 로드가 시작될 주소는 0x46000000 이며 mtdparts 변수(파티션 나누는 변수)는 왼쪽부터 부트로더, U-Boot 환경변수, 무선 랜 칩셋 데이터, U-Boot 소프트웨어, 해당 임베디드 시스템 핵심인 메인 파티션(RootFS)이다.


mtdparts 주소를 16진수로 변환하면 다음과 같다:
| 파티션 이름 | 크기 (KB / MB) | 크기 (16진수) | 시작 주소 (Start) | 끝 주소 (End) |
|---|---|---|---|---|
| bl2 | 1024 KB (1 MB) | 0x00100000 | 0x00000000 | 0x000FFFFF |
| u-boot-env | 512 KB (0.5 MB) | 0x00080000 | 0x00100000 | 0x0017FFFF |
| factory | 2048 KB (2 MB) | 0x00200000 | 0x00180000 | 0x0037FFFF |
| fip | 2048 KB (2 MB) | 0x00200000 | 0x00380000 | 0x0057FFFF |
| ubi | 110 MB | 0x06E00000 | 0x00580000 | 0x0737FFFF |
우리가 필요한 영역은 ubi 부분이다. 그러나, 이번 실습은 SPI 직접 통신이 아닌 md.b(Memory Display by Byte)를 통해 메모리 값 출력 시 이를 저장하는 방식으로 할 것이라 상대적으로 용량이 적은 fip(U-Boot)을 추출한다.
nand read 0x46000000 0x380000 0x2000000 명령어로 RAM 시작 위치인 0x46000000 메모리 주소에 0x380000 부터 0x2000000 을 더한 0x0057FFFF 까지 로드한다.
메모리 로드한 뒤 md.b 0x46000000 0x2000000 을 이용해 0x46000000부터 0x2000000을 더한 영역을 출력한다.

출력 시 hex 형태로 수없이 많은 데이터가 출력된다. 그러나 우리 목적은 이를 추출해 분석하는 것이기에 해당 데이터를 저장하는 작업이 필요하다. 이는 파이썬 코드로 간단히 해결 가능하다. 우선 Tera Term 프로그램 종료하자(파이썬으로 시리얼 콘솔 접근을 위함)
정상 덤프 중이다. UBI 영역도 동일하게 주소 변경 후 덤프하면 된다. 덤프 코드는 포스트 하단 별첨 1. md.b 덤프 파이썬 코드 참고바란다.

3.3. U-Boot 환경변수 수정을 통한 쉘 획득 후 펌웨어 덤프
3.2. U-Boot md.b 명령어를 이용한 덤프에서 SPI 플래시 메모리 구조에서 메인 파일시스템(UBI)를 확인했다. UBI(Unsorted Block Images)는 플래시 메모리 관리를 위한 게층이다. U-Boot 콘솔에서 UBI를 위한 여러 명령어가 존재하며, 이들을 이용해 해당 임베디드 시스템 쉘 획득이 가능하다.
ubi part ubi(파일시스템 명)을 이용해 파일시스템 마운트한다.

ubi info layout 명령을 이용해 UBI 파일시스템 내 어떠한 볼륨들이 있는지 확인 시 kernel2, 1 rootfs2, 1이 존재한다. 그러나 문제는 kernel이 2개라 어떤 커널이 옳은 건지 모른다는 것이다. 우선 첫번째 볼륨인 kernel 2를 정상으로 추측하고 로드 후 실행한다.

setenv 명령어로 bootargs 설정하는데, 이는 ttyS0의 115200 baudrate인 콘솔에 init 스크립트로 /bin/sh을 실행하라는 뜻이다. bootargs는 U-Boot 부트로더가 리눅스 커널에 전달하는 매개변수다. 이 값을 최초 실행한다 보면 된다.

로딩된 커널 로그를 보면 커널 커맨드에 사전 삽입한 bootargs 변수가 정상 반영되었다.

최종적으로 /bin/sh가 init 프로세스로 실행되고 쉘이 터미널에 떨어졌다.

추가로 커널 로딩 간 출력된 로그를 자세히 보자. 사전 확인했던 UBI 파일시스템(110M)이 mtd5에 마운트됐다. 이는 mtd5 덤프 시 ubi를 덤프할 수 있다는 의미다. 그러나, 이를 위해선 사전 필요조건이 있다. 내 PC와 하드웨어 디바이스 간 LAN 연결이 동반돼야 한다.

랜 케이블로 상호 연결한다.

현재 DHCP 활성화 되어있지 않기에 공유기 IP와 내 IP 수동 지정이 필요하다.
우선 공유기 IP 지정부터 확인하자. 네트워크 인터페이스 카드(NIC, Network Interface Card)는 총 6개지만, 4~7번까지는 lanX/eth0로 되어있다. 사전 확인한 공유기 랜 포트를 보면 노란색 랜 포트 캡과 주황색 랜 포트 캡이 있었고 주황색은 4개가 하나로 묶여져 있었다. 이로 eth0은 주황색, eth1은 노랑색이다 추측할 수 있다.

ip link set NIC up 으로 NIC 활성화 시 eth1 경우 링크 활성화(links becomes ready)됐다는 문구를 봐서 eth1이 우리가 연결한 NIC다.

ip 명령어로 eth1 NIC에 192.168.0.2/24 IP 할당한다.

윈도우즈 PC도 네트워크 연결 화면에서 연결한 이더넷 확인 후 IP 수동 설정한다.

핑 테스트 결과 정상이다.

남은 건 nc로 파일 전달하는 것이다. 윈도우즈에서 nc 바이너리 다운로드한다. 다운로드 시 nc는 윈도우즈 디펜더에서 막으니, 잠시 디펜더 비활성화 해야 한다.(링크: https://github.com/int0x33/nc.exe/blob/master/nc64.exe)
ncat -lp 옵션준 뒤 오픈할 포트 명시한 뒤 입력된 값을 test.bin으로 저장한다.(ncat 실행은 powershell이 아닌 cmd에서 실행해야 불필요 인코딩이 삽입되지 않아 정상 저장된다.)

공유기 쉘에서 dd 명령어를 이용해 /dev/mtd5 출력하고 이를 nc로 내 쪽 PC에 전달한다.

덤프된 파일 타입 확인 시 ubi 파일이다. ubireader_extract_images 툴을 이용해 추출한다.

ubi 내 파일시스템 확인 시 squash 파일시스템이다. 이는 binwalk로 해제한다.


펌웨어 덤프 완료되었다.

별첨 1. md.b 덤프 파이썬 코드
import serial
import time
import re
import sys
PORT, BAUD, TIMEOUT = 'COM3', 115200, 0.1
hex_regex = re.compile(r'^[0-9a-fA-F]{8}:(.*)')
TOTAL_SIZE = 0x200000 # 2MB (2,097,152 Bytes)
with serial.Serial(PORT, BAUD, timeout=TIMEOUT) as ser:
print("[*] 장치를 켜주세요. 부트 메뉴 대기 중...")
# 1. "u-boot console" 키워드만 검사하여 진입
while True:
line = ser.readline().decode('utf-8', errors='ignore')
if line:
print(line.strip())
if "u-boot console" in line.lower():
print("\n[*] 메뉴 감지! 콘솔 진입 신호('0') 송신.")
ser.write(b'0\n')
break
# 셸 프롬프트 안정화 및 버퍼 정리
time.sleep(1.0)
ser.reset_input_buffer()
# 2. NAND에서 RAM으로 데이터 로드
print("[*] NAND 데이터를 RAM으로 로드...")
ser.write(b"nand read 0x46000000 0x380000 0x200000\n")
time.sleep(2.0)
ser.reset_input_buffer()
# 3. 데이터 덤프 및 .bin 변환
print("[*] md.b 덤프 및 바이너리 변환 시작...")
ser.write(b"md.b 0x46000000 0x200000\n")
written_bytes = 0
with open("fip_dump.bin", "wb") as f:
no_data = 0
while no_data < 30:
line = ser.readline().decode('utf-8', errors='ignore').strip()
if line:
no_data = 0
match = hex_regex.match(line)
if match:
hex_parts = match.group(1).split()[:16]
hex_str = "".join([h for h in hex_parts if len(h) == 2])
if hex_str:
data_bytes = bytes.fromhex(hex_str)
f.write(data_bytes)
# 진행 상황 계산 및 한 줄 업데이트 출력
written_bytes += len(data_bytes)
pct = (written_bytes / TOTAL_SIZE) * 100
bar = "#" * int(pct // 5) + "-" * (20 - int(pct // 5))
sys.stdout.write(f"\r[{bar}] {pct:.1f}% ({written_bytes}/{TOTAL_SIZE} Bytes)")
sys.stdout.flush()
else:
no_data += 1
print("\n[*] 완료: fip_dump.bin 파일이 생성되었습니다.")
