[KOR] Exploiting the Unexpected: A Pwn2Own-Level Break of a Printer
[KOR] Exploiting the Unexpected: A Pwn2Own-Level Break of a Printer
요약
Team PetoWorks는 아일랜드에서 개최된 Pwn2Own 2025에 참가해 Canon imageCLASS MF654Cdw 프린터 익스플로잇에 성공, $10,000의 상금을 수상했습니다. 해당 대회에서 CVE-2025-14233 취약점을 활용하였으며, 이번 글에서 저희가 수행한 연구를 기술적으로 공유하고자 합니다.
소개
이 대회가 생소한 분들을 위해 설명하자면, Pwn2Own은 일종의 해킹 올림픽입니다. Trend AI의 Zero Day Initiative가 주최하는 제로데이 취약점 대회로, 매년 1월, 5월, 10월에 총 세 차례 열립니다. 개최 도시와 공격 대상은 매번 달라집니다.
이 대회에서 참가자들은 각자 준비한 익스플로잇을 선보입니다. 대상 기기는 및 소프트웨어는 최신 펌웨어를 탑재하고 모든 패치가 적용된 상태입니다. 무대 위에서 주어진 시간 안에 취약점 공격을 성공시켜야 하며, 성공하면 상금을 받습니다. 발견된 모든 취약점은 대회 후 공급업체에 책임감 있게 공개됩니다.
2025년 아일랜드 대회의 카테고리는 다음과 같았습니다.
Mobile Phones
Smart Home
Printers
Surveillance Systems
Home Automation Hub
Network Attached Storage
Messaging
Wearables
Small Office / Home Office(SOHO)
저희가 타겟한 대상은 Printers 카테고리의 Canon imageCLASS MF654Cdw이며, 내부엔 DryOS로 구현되어 있습니다.
DryOS는 RTOS(Real-Time Operating System)입니다. RTOS의 핵심은 결정론적 실행입니다. 즉, 작업은 엄격한 시간 제한 내에서 수행되며, 스케줄링은 더 엄격하고, 인터럽트 처리는 철저히 관리됩니다.
정찰
먼저 펌웨어가 실제로 어디에 위치하는지부터 살펴보겠습니다.
부트로더는 플래시 메모리에, 펌웨어 자체는 eMMC에 저장되어 있습니다. 부팅 시 부트로더가 eMMC에서 펌웨어를 RAM으로 불러와 실행을 시작합니다.
vers를 입력하면 모든 정보를 알려줍니다. DryOS 버전 2.3, release 59입니다.
디컴파일러로 펌웨어를 살펴보던 중, xd나 xm 같은 디버그 명령어가 제거된 것을 발견했습니다. 이 명령어들은 각각 메모리 덤프와 메모리 수정 기능을 담당합니다.
하지만 사실 완전히 사라진 건 아니었습니다. 단지 도움말에서 숨겨져 있을 뿐이었죠. IDA에서 usage: 문자열을 검색하면 또 다른 메모리 덤프 명령어를 찾을 수 있습니다. 나중에 익스플로잇 개발 과정에서 이 명령어들을 활용했습니다.
취약점 발견
먼저 공격 표면을 매핑했습니다. 프린터가 네트워크에 노출하는 항목은 다음과 같습니다.
웹 관리 인터페이스를 위한 HTTP/HTTPS, 인쇄를 위한 LPD·IPP·Jetdirect, 그리고 탐색을 위한 NetBIOS·SNMP·SLP·WSD·mDNS. 모두 표준 RFC 규격입니다. 그런데 맨 아래를 보면 캐논의 독자 프로토콜이 등장합니다. RFC도, 공개된 사양도 없이 캐논만의 방식을 고수하고 있습니다.
저희가 고려한 주요 공격 경로는 세 가지였습니다.
RFC 프로토콜 구현상의 취약점입니다. 벤더들은 표준을 엉성하게 재구현하곤 하니까요.
파싱 버그입니다. 폰트, PDF, PostScript, PCL, 이미지 등 프린터가 복잡한 입력을 해석해야 하는 모든 지점이 대상입니다. 프린터는 신뢰할 수 없는 데이터를 파싱합니다.
Canon사의 독자 Custom 프로토콜들입니다.
지난 수년간 캐논에서 발생한 RFC 구현 버그를 간략히 살펴보면 패턴은 일관됩니다. 벤더가 표준 프로토콜을 재구현하면 매번 깨집니다.
다음은 파싱 문제입니다. 인쇄 데이터 경로는 완벽한 공격 표면입니다. 신뢰할 수 없는 입력, 파싱, 그리고 수십 년간 쌓여 온 복잡한 형식들이 모두 존재하기 때문입니다.
저희는 정적 분석, 오디팅, 퍼징 등 다양한 취약점 분석 기술들을 사용하여 분석을 수행했습니다.
실제로 취약점은 퍼징으로 트리거되었습니다. 크래시가 발생했을 때, UART 콘솔에 직접 출력된 작업 중단 메시지였습니다. 레지스터 덤프, 작업 이름, PC, PSR 등 모든 정보가 포함되어 있었습니다.
Dry> < Error Exception >
CORE : 0
TYPE : abort
ISR : FALSE
TASK ID : 14
TASK Name : cadm_proc_adm
R 0 : 4676eb54
R 1 : 2d8fb5e9
R 2 : 46524545
R 3 : 20000013
R 4 : 4c22f6e4
R 5 : 4676eb54
R 6 : 0000009d
R 7 : 4c09dae4
R 8 : 4c0acb10
R 9 : 4c0acb10
R10 : 4648843c
R11 : 4b8b3018
R12 : 00000000
R13 : 4b8b2e30
R14 : 417af778
PC : 40cb319c
PSR : 60000013
CTRL : 00c5187d
IE(31)=0
TE(30)=0
AFE(29)=0
TRE(28)=0
EE (25)=0
VE (24)=0
XP (23)=1
U (22)=1
FI (21)=0
DZ (19)=0
IT (18)=1
BR (17)=0
DT (16)=1
L4 (15)=0
RR (14)=0
V (13)=0
I (12)=1
Z (11)=1
F (10)=0
R ( 9)=0
S ( 8)=0
B ( 7)=0
W ( 3)=1
C ( 2)=1
A ( 1)=0
M ( 0)=1
DOM : 55555555
DFSR : 00000805
IFSR : 00000400
FAR : 2d8fb5d9
McTimer(cadm_initEvent) Watchdog reset in10 sec!
info: SystemLogger recv event(0x00000001)
info: abusStatLogFunc Start. ===================================
stat:
sent messages = 909187
rcvd messages = 909147
send errors = 0
recv errors = 0
Dry> < Error Exception >
CORE : 0
TYPE : abort
ISR : FALSE
TASK ID : 14
TASK Name : cadm_proc_adm
R 0 : 4676eb54
R 1 : 2d8fb5e9
R 2 : 46524545
R 3 : 20000013
R 4 : 4c22f6e4
R 5 : 4676eb54
R 6 : 0000009d
R 7 : 4c09dae4
R 8 : 4c0acb10
R 9 : 4c0acb10
R10 : 4648843c
R11 : 4b8b3018
R12 : 00000000
R13 : 4b8b2e30
R14 : 417af778
PC : 40cb319c
PSR : 60000013
CTRL : 00c5187d
IE(31)=0
TE(30)=0
AFE(29)=0
TRE(28)=0
EE (25)=0
VE (24)=0
XP (23)=1
U (22)=1
FI (21)=0
DZ (19)=0
IT (18)=1
BR (17)=0
DT (16)=1
L4 (15)=0
RR (14)=0
V (13)=0
I (12)=1
Z (11)=1
F (10)=0
R ( 9)=0
S ( 8)=0
B ( 7)=0
W ( 3)=1
C ( 2)=1
A ( 1)=0
M ( 0)=1
DOM : 55555555
DFSR : 00000805
IFSR : 00000400
FAR : 2d8fb5d9
McTimer(cadm_initEvent) Watchdog reset in10 sec!
info: SystemLogger recv event(0x00000001)
info: abusStatLogFunc Start. ===================================
stat:
sent messages = 909187
rcvd messages = 909147
send errors = 0
recv errors = 0
Dry> < Error Exception >
CORE : 0
TYPE : abort
ISR : FALSE
TASK ID : 14
TASK Name : cadm_proc_adm
R 0 : 4676eb54
R 1 : 2d8fb5e9
R 2 : 46524545
R 3 : 20000013
R 4 : 4c22f6e4
R 5 : 4676eb54
R 6 : 0000009d
R 7 : 4c09dae4
R 8 : 4c0acb10
R 9 : 4c0acb10
R10 : 4648843c
R11 : 4b8b3018
R12 : 00000000
R13 : 4b8b2e30
R14 : 417af778
PC : 40cb319c
PSR : 60000013
CTRL : 00c5187d
IE(31)=0
TE(30)=0
AFE(29)=0
TRE(28)=0
EE (25)=0
VE (24)=0
XP (23)=1
U (22)=1
FI (21)=0
DZ (19)=0
IT (18)=1
BR (17)=0
DT (16)=1
L4 (15)=0
RR (14)=0
V (13)=0
I (12)=1
Z (11)=1
F (10)=0
R ( 9)=0
S ( 8)=0
B ( 7)=0
W ( 3)=1
C ( 2)=1
A ( 1)=0
M ( 0)=1
DOM : 55555555
DFSR : 00000805
IFSR : 00000400
FAR : 2d8fb5d9
McTimer(cadm_initEvent) Watchdog reset in10 sec!
info: SystemLogger recv event(0x00000001)
info: abusStatLogFunc Start. ===================================
stat:
sent messages = 909187
rcvd messages = 909147
send errors = 0
recv errors = 0
CPCA(Common Peripheral Controlling Architecture) 프로토콜 기능에서 취약점이 발생했습니다. 해당 프로토콜은 캐논의 독자 프로토콜로, 인쇄·복사·스캔·메일박스 관리 등 MFP(다기능 프린터)의 거의 모든 기능을 제어합니다.
리버스 엔지니어링을 통해 CPCA의 와이어 포맷을 파악했습니다. 20바이트 헤더로, 매직 바이트 0xCDCA, 버전, 응답 코드, 오퍼코드, 페이로드 길이, 몇 가지 예약 필드, 패딩으로 구성되고 그 뒤에 가변 길이 페이로드가 이어집니다.
다음은 몇 가지 오퍼레이션 코드입니다. 전체적으로는 수십 가지가 있습니다.
디스패처는 핸들러 테이블 형태이며, 각 핸들러는 오퍼레이션 코드, 길이, 그리고 디코딩·인코딩·릴리스를 담당하는 함수 포인터를 갖습니다.
오퍼레이션 코드
함수
0x01
에코
0x0c
사용자 추가
0x0d
사용자 삭제
0x0e
사용자 비밀번호 변경
0x2b
종료
0x49
파일 생성
0x50
사용자 암호 확인
0x5f
파일 삭제
…
…
여기서 저희가 찾은 취약점이 발생하는 Opcode는 0x5f(파일 삭제)입니다.
버그는 다음 부분에 있습니다.
ptr = alloc(sizeof(DeleteFiles)); // ------- [1]ptr->file_ids = alloc(4 * file_count);
if (error)
gotofree_logic;
for (i = 0; i < file_count; i++){ // ------- [2]free(&ptr->file_ids[i]); // bug
}
free(ptr
ptr = alloc(sizeof(DeleteFiles)); // ------- [1]ptr->file_ids = alloc(4 * file_count);
if (error)
gotofree_logic;
for (i = 0; i < file_count; i++){ // ------- [2]free(&ptr->file_ids[i]); // bug
}
free(ptr
ptr = alloc(sizeof(DeleteFiles)); // ------- [1]ptr->file_ids = alloc(4 * file_count);
if (error)
gotofree_logic;
for (i = 0; i < file_count; i++){ // ------- [2]free(&ptr->file_ids[i]); // bug
}
free(ptr
[1] 할당 흐름을 보면, deleteFiles 핸들러는 먼저 구조체를 할당하고, 그 안에서 file_ids라는 단일 연속 버퍼를 4 * file_count 크기로 할당합니다. 하나의 연속된 버퍼이며, 지극히 일반적인 방식입니다. [2] 그다음 각 file_id 요소를 순회하며 각 요소의 주소에 대해 free를 호출합니다.
하지만 file_ids는 하나의 버퍼일 뿐, 개별 할당된 포인터의 배열이 아닙니다. free()에 넘길 수 있는 유효한 값은 기본 주소뿐입니다. 1번, 2번, 3번 요소를 따로 해제할 수는 없고, 오직 전체를 한 번에 해제해야 합니다. 따라서 이 오류 경로에서는 한 번도 할당된 적 없는 포인터에 대해 free를 호출하게 됩니다. 반복할 때마다 무효한 free 호출이 발생하는 것이죠.
상황을 그림으로 나타내면 다음과 같습니다.
왼쪽은 의도된 동작으로, free(file_ids)를 한 번만 호출합니다. 오른쪽은 실제로 일어나는 일로, 세 번의 무효한 free 호출이 발생합니다. 개별적으로 할당된 적 없는 메모리를 세 번에 걸쳐 해제하려 시도하는 것입니다. 이는 명백한 무효 free이며 힙 손상을 일으킵니다.
그런데 정말 흥미로운 부분은 여기서 시작됩니다. 만약 file_ids의 내용을 저희가 제어할 수 있고 free 로직이 각 요소를 포인터처럼 취급한다면, 결국 free에 전달되는 주소를 제어할 수 있게 됩니다. 즉, Arbitrary Address Free를 수행할 수 있습니다.
이 버그는 컴파일러가 잡아낼 수 없습니다. free()에 전달되는 포인터는 타입상으로는 올바른 void*이기 때문에, 문법 오류도 타입 위반도 없어 컴파일러 입장에서는 아무런 문제가 보이지 않습니다. 그 결과 컴파일 시점에는 멀쩡히 통과하고, 런타임에 이르러서야 free(): invalid pointer 오류와 함께 크래시가 발생합니다.
정적 분석이 이 버그를 놓치는 이유도 같은 맥락입니다. 이 버그는 문법적으로 잘못된 것이 아니라 의미적으로 잘못된(semantically wrong) 코드이기 때문입니다. 코드가 표현하는 의도(연속 버퍼 하나를 통째로 해제)와 실제 동작(각 요소를 개별 할당된 포인터처럼 하나씩 해제)이 어긋나 있는데, 이런 의미 수준의 오류는 타입 검사나 문법 검사만으로는 드러나지 않습니다.
악용
버그를 본격적으로 다루기 전에, 공격할 메모리 모델을 먼저 정리해 보겠습니다. DryOS 힙 청크는 16바이트 단위로 정렬되어 있으며, 각 청크는 태그 필드, 크기 필드, 다음 포인터, 그리고 예약 필드를 포함합니다. 핵심은 바로 이 예약(Reserved) 필드였습니다. 이 필드는 힙 관리자 자체에서는 사용되지 않고, 오직 정렬을 유지하기 위해서만 존재합니다.
그렇다면, 이 버그는 실제로 어떻게 작동할까요?
이 버그는 객체 내부의 값들을 순차적으로 검사하며 하나씩 해제합니다. 배열 주소에 0x10 오프셋을 더한 지점을 또 다른 힙 청크의 시작점으로 인식하기 때문입니다. 여기서 실질적인 문제가 발생합니다.
잘못된 주소를 해제하면, 심지어 0처럼 명백히 잘못된 값이라도, DryOS는 즉시 오류를 발생시킵니다. 따라서 저희는 해당 필드에 제어된 값을 기록해 오류를 방지할 방법이 필요했고, 이는 익스플로잇의 신뢰성을 위해 매우 중요했습니다.
DryOS는 적절한 크기의 해제된 청크를 찾을 때까지 힙 청크를 하나씩 탐색하고, 찾지 못하면 최상위 청크로 되돌아갑니다. 이 동작이 예측 가능하다는 점을 이용하면, 버그를 유발하기 전에 원하는 레이아웃대로 할당을 배치할 수 있습니다.
아이디어는 간단합니다.
먼저 할당기가 최상위 청크에서 큰 청크를 할당하도록 유도합니다.
이를 해제한 뒤, 같은 영역에 더 작은 청크를 다시 할당합니다.
이렇게 하면 해제된 영역을 제어된 방식으로 분할하고 점유할 수 있습니다.
힙 레이아웃이 준비되면 버그를 유발합니다.
다음은 저희가 할당한 세 번째 청크의 실제 레이아웃입니다.
첫 번째 청크의 데이터 영역을 재사용하기 때문에, 첫 번째 청크에 스프레이한 값을 피해 청크의 Reserved 필드로 가져올 수 있습니다. 이 설정을 통해 대상 주소를 피해 청크의 Reserved 필드에 배치할 수 있습니다.
CPCA는 CADM이라는 관리자 기능을 제공합니다. CADM 안에서 유용했던 기능 중 하나가 에코(echo) 함수였습니다. 이 경로는 사용자가 직접 호출할 수 있고, 코드에서 두 개의 할당을 확인할 수 있습니다. [1] 메모리 할당 크기는 사용자가 보낸 2바이트 값으로 결정되고, [2] 저희가 보낸 데이터는 할당된 영역으로 그대로 복사됩니다. 따라서 요청 크기를 제어함으로써 필요한 큰 청크를 할당하고 그 영역에 데이터를 쓸 수 있었습니다.
이 방식으로 안정적인 임의 힙 해제(Arbitrary Heap Free)를 달성할 수 있었습니다. 임의 힙 해제를 확보한 다음 떠오르는 질문은 '그래서 어떤 주소를 해제할 것인가'입니다. CADM 함수를 분석하던 중, 펌웨어가 핸들러 테이블을 순회하며 일치하는 오퍼코드를 찾는다는 사실을 발견했습니다.
펌웨어는 일치하는 항목을 찾으면 그 구조체에 저장된 핸들러 함수를 호출합니다. 호출자와 핸들러 모두 pjcc_ 접두사가 붙은 심볼을 사용하기 때문에, 이 테이블을 PJCC 핸들러 테이블이라 명명했습니다. 저희의 아이디어는 이 PJCC 핸들러 테이블을 해제하고 그 자리를 원하는 값으로 다시 채우는 것이었습니다.
그래서 테이블의 시작 주소를 해제한 뒤, 같은 자리에 저희만의 영역을 다시 할당해 가짜 PJCC 핸들러 테이블을 구성합니다. 그런 다음 CADM 에코 함수를 재사용해 그 영역을 원하는 데이터로 채우고, CPCA를 다시 전송합니다. 그러면 펌웨어는 저희가 만든 가짜 핸들러 테이블을 참조해, 임의 주소를 호출하게 됩니다.
[1] 첫 번째 명령어는 R4 값에 4를 더해 R0에 저장합니다. [2] 그다음 R0 값을 스택 포인터로 옮기고, [3] 스택 포인터를 기준으로 데이터를 읽어 각 레지스터에 로드합니다. [4] 마지막으로 에필로그가 이어집니다.
이 가젯은 태스크 전환 과정에서 사용되는 코드로 보입니다. 덕분에 R4에 저장된 주소를 기준으로 대부분의 레지스터와 스택을 원하는 대로 설정할 수 있습니다.
PC 제어권을 확보한 뒤에도 셸코드로 곧장 점프할 수는 없었습니다. DryOS가 영역별로 메모리 권한을 강제하는 것으로 보였기 때문입니다. 쓰기 권한을 가진 영역은 모두 실행 불가능 영역이었기에, 그곳으로 점프하려 하면 DryOS가 프리페치 오류를 일으켰습니다. 다행히 Neodyme 팀의 선행 연구가 이 문제의 해법을 제시해 주었습니다.
저희가 표적으로 삼은 캐논 프린터는 ARM CPU를 사용합니다. ARM CPU에서 MMU 접근은 주로 도메인을 통해 제어됩니다. 총 16개의 도메인이 있고, 각 도메인은 접근 권한을 정의하는 2비트 필드를 가지며, 이 도메인들은 도메인 액세스 제어 레지스터(DACR)로 구성됩니다. 해당 레지스터를 관리자(manager) 모드로 설정하면 메모리 접근 검사가 우회됩니다. 따라서 주입한 셸코드로 점프하기 전에, 먼저 DACR을 관리자 모드로 설정하는 가젯으로 점프하고, 그 후에야 셸코드로 점프했습니다.
Pwn2Own 시연을 위해서는 개념 증명(PoC) 코드가 필요했습니다. RTOS이다보니, 증명하기 위해서 Display 수정하고, 소리 출력 등을 수행하도록 만들었습니다.
이를 위해 프레임 버퍼를 수정하는 셸코드를 작성했습니다. 이 장치는 800×480 해상도의 디스플레이를 갖추고 있고 픽셀마다 3바이트의 RGB 데이터가 필요하기 때문에, 셸코드는 네트워크를 통해 약 1MB의 이미지 데이터를 수신해 프레임 버퍼에 기록함으로써 화면을 변조합니다. 이후 이미지 센서를 활성화하는 함수를 호출해, 저희가 작성한 프레임이 디스플레이에 표시되도록 했습니다.
익스플로잇에 필요한 모든 문제를 해결한 뒤, 전체 체인을 구성할 준비가 되었습니다. 테스트 결과 이 체인은 약 90%의 성공률을 보였습니다. 실패하더라도 기기는 대개 오류 후 재부팅되었기 때문에 그냥 다시 시도하면 되었습니다.
패치 결과를 분석한 결과, 이 취약점이 다수의 Canon 기기에 영향을 미치는 것으로 확인되었습니다.
패치된 펌웨어가 출시된 직후, 곧바로 Patch Diffing을 수행했습니다.
패치가 문제를 제대로 해결했음을 확인했습니다. 이전 버전과 달리 더 이상 힙 버퍼를 순회하며 각 값을 잘못 해제하지 않고, 힙 버퍼 자체를 올바르게 한 번에 해제합니다.
다음은 실제 대회 현장에서 해킹한 장면입니다.
결론
표준화된 프로토콜이라고 해서 그 구현까지 자동으로 안전해지는 것은 아닙니다. 명확히 정의된 RFC가 있더라도, 사소한 구현상의 실수 하나가 악용 가능한 취약점으로 이어질 수 있습니다. 또한 프린터는 그 특성과 아키텍처상 인증 전(pre-auth) 공격 경로가 많습니다. 공격자는 이렇게 노출된 공격 표면을 통해 다양한 버그를 악용할 수 있으므로 결코 가볍게 보아서는 안 됩니다.
DryOS는 악용 방지(mitigation) 기능이 상대적으로 제한적입니다. 이 때문에 단 하나의 메모리 손상 버그만으로도 공격자가 임의 코드 실행으로 이어지는 충분한 제어권을 확보할 수 있습니다. 게다가 프린터는 흔히 내부 네트워크에 연결되어 있으면서도 서버나 워크스테이션만큼 면밀하게 모니터링되지 않습니다. 그래서 프린터는 공격자에게 매력적인 은신처이자 진입점이 됩니다. 일단 침해되면 프린터는 네트워크 내부의 발판으로 쓰일 수 있습니다. 따라서 임베디드 기기는 단순한 주변 기기가 아니라 중요한 보안 경계로 다루어야 합니다.
PetoWorks는 바로 이러한 영역, 즉 펌웨어와 임베디드 기기, 독자 프로토콜처럼 일반적인 보안 점검이 닿기 어려운 공격 표면을 깊이 파고드는 공격적 보안 연구(Offensive Security Research)를 전문으로 합니다. 바이너리 익스플로잇부터 펌웨어 취약점 분석, 표면적인 점검만으로는 드러나지 않는 실질적 위협을 찾아내는 것이 저희의 일입니다. 관련 연구나 협업, 보안 점검이 필요하시다면 언제든 PetoWorks에 문의해 주시기 바랍니다.
다시 한번 이번 대회를 주최해 주신 Zero Day Initiative 팀에 감사드립니다. 그리고 신속히 대응하고 적절한 패치를 제공해 주신 Canon 벤더사에도 감사의 말씀을 전합니다.