[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으로 불러와 실행을 시작합니다.


흥미로운 점이 하나 있습니다. 이 장치는 UART 디버그 인터페이스를 제공합니다.


UART를 연결하자 셸이 활성화되었습니다. 결과는 다음과 같습니다.

DryOS Shell Interactive

Dry> vers
DRYOS version 2.3, release #0059  Dry-MK 2.66
  Dry-DM 1.21
  Dry-FSM 0.10
  Dry-EFAT 1.22
  Dry-stdlib 1.57
  Dry-PX 1.15
  Dry-drylib 1.22
  Dry-shell 1.19
  Dry-command alpha 065
  
Dry> help
[Debug]
 task  sem  event  mq  mutex  cond  timer  barrier  itsk  isem  iflg  imbx
 idtq  impf  impl  icyc  mkobjsize  kill  suspend  resume  release  delete
 prio  mkcfg  objinfo  meminfo  xd  xm  cmp  pxthr  pxmq  stdlibcfg  efatcfg
 cp  chmod  mv  rm  cat  kzmenu  dumpstack  sfr_dump  sfr_write
 sfr_sectorErase  sfr_blockErase  emmcfs  syslog  mcfs  emmcread
[Test]
 time  count  mktest  iotest  chkspi  chkit4  chkwrite  chkread  fwrite  fread
 truncate
[Miscellaneous]
 date  vers  exit  shutdown  dminfo
[File System]
 touch  stfs  ls  mkdir  rmdir  sync  pwd  cd  cdev  input  less
[ISSARM]
 sample  firmstart  chkcachelib  chkvfp  chkvfpregs
[Network]
 netvers  eci  arp  dhcpc  slaup  ifconfig  mbufs  netstat  route  ping
 rdinfo  tcputil  dnsutil  mib2  ipf  ipsec  ike
[Network Util]
 autoip  host  nsupdate  arpsend  sockhalt  mctalk  echod  gtime  netecho
 loosesock  netrace
[MFP_MANAGER]
 mfpm
[DOC_PRODUCER]
 pailog  gptlog  xpm  xrm_dump_dl  xrm_dump_dl_range  xrm_dump_dl_mode
 xrm_dump_dl_addr  xrm_dsp  adb_debug
[DOC_WRITER]
 izpr
[DOC_PIPELINE]
 spooler
[APP_LOGIC]
 printer  pdriver
[IMG_LIB]
 fcotline  dumpimlbaddrlist  dmplutdirectmode  readdmplut
[DATABASE]
 db  dtdc
[UTILITY]
 memory  version  reboot  tchamp  mcsf  mcel  mcam  mcst  mced  mceb  mctfc
 mcse  mcli  updt  watchdog  mcstusb  calib
[DEVICE]
 uisim  uidev  deci  reader  dfmot  bkmot  cis_data  rdbramini  rcon
[NETWORK]
 ntat  ntwl  ntdc  nteh  ntwk2  uartdebug  silabs  siocheck
[OS_WRAPPER]
 imsrcmd
[FUNC_CHECKER]
 target  romver  romsum  spk  fct_spkchk  rtc_r  rtc_w  dram  fct_w  fct_r
 model_type  model_read  outport  inport  outport2  inport2  modem  g3_tx
 g3_rx  tonal_out  tonal_in  tonal_end  dtmf_tx  dtmf_rx  c01_rx  mdm_rl
 mdmrev  cardreader_if  sram  dramsize  fram  onboard_fram  all_clr  trip
 spi_clr  spi_r  spi_w  clear_fctflg  fct_usbloop  fct_checkstart  fct_disp
 fct_lcdchk  fct_lcddata  fct_lcdclk  fct_lcdpwm  fct_buzzer  fct_led  fct_key
 fct_touchlog  fct_bklight  fct_ess  soft_type  fct_CheddarImg
 fct_panelCheckMode  fct_panel_a0_chk  fct_panel_data0_chk
 fct_panel_data1_chk  fct_panel_data2_chk  fct_panel_lcdclk_chk  fct_rstlcd
 fct_panelloop  fct_devset  fct_printdevset  fct_nfcread  fct_nfcwrite
 fct_lvdsvideo  fct_lvdspanel  fct_usbDeviceType  fct_dbClear  fct_waitDBWrite
 efunc_check  usb_host  mac_r  mac_w  set_val_ping  ipadd_set  wlan  wlan_if
 usbid_r  usbid_w  fct_scan  scandat_send  csi
[Panel]
 panel_verup  panel_recovup
[UserDataErase]
 UDEexec
[eMMC]
 emmc  emmc_wrv  emmc_wfchk
[serialFlashROM]
 sfr_showInfo
[SUBIF]
 nsclog
[CAPI]
 ct  mon
[WELL]
 nell-attach  nell-detach  nell-wakeup  nlog  up  down  stat  scan  join
 leave  wset  wget  wep  w12get  w12set  elog  uap  wfd
[TMN]
 tm  rt  w2t
[WPSE]
 wpse  wpscmd  wpsc
[AOSS]
 aoss
[WCF]
 wcf  wcfcmd
[ALINK]
 alinkcmd
[WP2PC]
 wp2pc
[IMS_Utility]
 imsconv  imsdump  imsprintmemout
[NFC]
 nfc_cmd
[WiFi]
 wprd  wtst
[Ethernet]

DryOS Shell Interactive

Dry> vers
DRYOS version 2.3, release #0059  Dry-MK 2.66
  Dry-DM 1.21
  Dry-FSM 0.10
  Dry-EFAT 1.22
  Dry-stdlib 1.57
  Dry-PX 1.15
  Dry-drylib 1.22
  Dry-shell 1.19
  Dry-command alpha 065
  
Dry> help
[Debug]
 task  sem  event  mq  mutex  cond  timer  barrier  itsk  isem  iflg  imbx
 idtq  impf  impl  icyc  mkobjsize  kill  suspend  resume  release  delete
 prio  mkcfg  objinfo  meminfo  xd  xm  cmp  pxthr  pxmq  stdlibcfg  efatcfg
 cp  chmod  mv  rm  cat  kzmenu  dumpstack  sfr_dump  sfr_write
 sfr_sectorErase  sfr_blockErase  emmcfs  syslog  mcfs  emmcread
[Test]
 time  count  mktest  iotest  chkspi  chkit4  chkwrite  chkread  fwrite  fread
 truncate
[Miscellaneous]
 date  vers  exit  shutdown  dminfo
[File System]
 touch  stfs  ls  mkdir  rmdir  sync  pwd  cd  cdev  input  less
[ISSARM]
 sample  firmstart  chkcachelib  chkvfp  chkvfpregs
[Network]
 netvers  eci  arp  dhcpc  slaup  ifconfig  mbufs  netstat  route  ping
 rdinfo  tcputil  dnsutil  mib2  ipf  ipsec  ike
[Network Util]
 autoip  host  nsupdate  arpsend  sockhalt  mctalk  echod  gtime  netecho
 loosesock  netrace
[MFP_MANAGER]
 mfpm
[DOC_PRODUCER]
 pailog  gptlog  xpm  xrm_dump_dl  xrm_dump_dl_range  xrm_dump_dl_mode
 xrm_dump_dl_addr  xrm_dsp  adb_debug
[DOC_WRITER]
 izpr
[DOC_PIPELINE]
 spooler
[APP_LOGIC]
 printer  pdriver
[IMG_LIB]
 fcotline  dumpimlbaddrlist  dmplutdirectmode  readdmplut
[DATABASE]
 db  dtdc
[UTILITY]
 memory  version  reboot  tchamp  mcsf  mcel  mcam  mcst  mced  mceb  mctfc
 mcse  mcli  updt  watchdog  mcstusb  calib
[DEVICE]
 uisim  uidev  deci  reader  dfmot  bkmot  cis_data  rdbramini  rcon
[NETWORK]
 ntat  ntwl  ntdc  nteh  ntwk2  uartdebug  silabs  siocheck
[OS_WRAPPER]
 imsrcmd
[FUNC_CHECKER]
 target  romver  romsum  spk  fct_spkchk  rtc_r  rtc_w  dram  fct_w  fct_r
 model_type  model_read  outport  inport  outport2  inport2  modem  g3_tx
 g3_rx  tonal_out  tonal_in  tonal_end  dtmf_tx  dtmf_rx  c01_rx  mdm_rl
 mdmrev  cardreader_if  sram  dramsize  fram  onboard_fram  all_clr  trip
 spi_clr  spi_r  spi_w  clear_fctflg  fct_usbloop  fct_checkstart  fct_disp
 fct_lcdchk  fct_lcddata  fct_lcdclk  fct_lcdpwm  fct_buzzer  fct_led  fct_key
 fct_touchlog  fct_bklight  fct_ess  soft_type  fct_CheddarImg
 fct_panelCheckMode  fct_panel_a0_chk  fct_panel_data0_chk
 fct_panel_data1_chk  fct_panel_data2_chk  fct_panel_lcdclk_chk  fct_rstlcd
 fct_panelloop  fct_devset  fct_printdevset  fct_nfcread  fct_nfcwrite
 fct_lvdsvideo  fct_lvdspanel  fct_usbDeviceType  fct_dbClear  fct_waitDBWrite
 efunc_check  usb_host  mac_r  mac_w  set_val_ping  ipadd_set  wlan  wlan_if
 usbid_r  usbid_w  fct_scan  scandat_send  csi
[Panel]
 panel_verup  panel_recovup
[UserDataErase]
 UDEexec
[eMMC]
 emmc  emmc_wrv  emmc_wfchk
[serialFlashROM]
 sfr_showInfo
[SUBIF]
 nsclog
[CAPI]
 ct  mon
[WELL]
 nell-attach  nell-detach  nell-wakeup  nlog  up  down  stat  scan  join
 leave  wset  wget  wep  w12get  w12set  elog  uap  wfd
[TMN]
 tm  rt  w2t
[WPSE]
 wpse  wpscmd  wpsc
[AOSS]
 aoss
[WCF]
 wcf  wcfcmd
[ALINK]
 alinkcmd
[WP2PC]
 wp2pc
[IMS_Utility]
 imsconv  imsdump  imsprintmemout
[NFC]
 nfc_cmd
[WiFi]
 wprd  wtst
[Ethernet]

DryOS Shell Interactive

Dry> vers
DRYOS version 2.3, release #0059  Dry-MK 2.66
  Dry-DM 1.21
  Dry-FSM 0.10
  Dry-EFAT 1.22
  Dry-stdlib 1.57
  Dry-PX 1.15
  Dry-drylib 1.22
  Dry-shell 1.19
  Dry-command alpha 065
  
Dry> help
[Debug]
 task  sem  event  mq  mutex  cond  timer  barrier  itsk  isem  iflg  imbx
 idtq  impf  impl  icyc  mkobjsize  kill  suspend  resume  release  delete
 prio  mkcfg  objinfo  meminfo  xd  xm  cmp  pxthr  pxmq  stdlibcfg  efatcfg
 cp  chmod  mv  rm  cat  kzmenu  dumpstack  sfr_dump  sfr_write
 sfr_sectorErase  sfr_blockErase  emmcfs  syslog  mcfs  emmcread
[Test]
 time  count  mktest  iotest  chkspi  chkit4  chkwrite  chkread  fwrite  fread
 truncate
[Miscellaneous]
 date  vers  exit  shutdown  dminfo
[File System]
 touch  stfs  ls  mkdir  rmdir  sync  pwd  cd  cdev  input  less
[ISSARM]
 sample  firmstart  chkcachelib  chkvfp  chkvfpregs
[Network]
 netvers  eci  arp  dhcpc  slaup  ifconfig  mbufs  netstat  route  ping
 rdinfo  tcputil  dnsutil  mib2  ipf  ipsec  ike
[Network Util]
 autoip  host  nsupdate  arpsend  sockhalt  mctalk  echod  gtime  netecho
 loosesock  netrace
[MFP_MANAGER]
 mfpm
[DOC_PRODUCER]
 pailog  gptlog  xpm  xrm_dump_dl  xrm_dump_dl_range  xrm_dump_dl_mode
 xrm_dump_dl_addr  xrm_dsp  adb_debug
[DOC_WRITER]
 izpr
[DOC_PIPELINE]
 spooler
[APP_LOGIC]
 printer  pdriver
[IMG_LIB]
 fcotline  dumpimlbaddrlist  dmplutdirectmode  readdmplut
[DATABASE]
 db  dtdc
[UTILITY]
 memory  version  reboot  tchamp  mcsf  mcel  mcam  mcst  mced  mceb  mctfc
 mcse  mcli  updt  watchdog  mcstusb  calib
[DEVICE]
 uisim  uidev  deci  reader  dfmot  bkmot  cis_data  rdbramini  rcon
[NETWORK]
 ntat  ntwl  ntdc  nteh  ntwk2  uartdebug  silabs  siocheck
[OS_WRAPPER]
 imsrcmd
[FUNC_CHECKER]
 target  romver  romsum  spk  fct_spkchk  rtc_r  rtc_w  dram  fct_w  fct_r
 model_type  model_read  outport  inport  outport2  inport2  modem  g3_tx
 g3_rx  tonal_out  tonal_in  tonal_end  dtmf_tx  dtmf_rx  c01_rx  mdm_rl
 mdmrev  cardreader_if  sram  dramsize  fram  onboard_fram  all_clr  trip
 spi_clr  spi_r  spi_w  clear_fctflg  fct_usbloop  fct_checkstart  fct_disp
 fct_lcdchk  fct_lcddata  fct_lcdclk  fct_lcdpwm  fct_buzzer  fct_led  fct_key
 fct_touchlog  fct_bklight  fct_ess  soft_type  fct_CheddarImg
 fct_panelCheckMode  fct_panel_a0_chk  fct_panel_data0_chk
 fct_panel_data1_chk  fct_panel_data2_chk  fct_panel_lcdclk_chk  fct_rstlcd
 fct_panelloop  fct_devset  fct_printdevset  fct_nfcread  fct_nfcwrite
 fct_lvdsvideo  fct_lvdspanel  fct_usbDeviceType  fct_dbClear  fct_waitDBWrite
 efunc_check  usb_host  mac_r  mac_w  set_val_ping  ipadd_set  wlan  wlan_if
 usbid_r  usbid_w  fct_scan  scandat_send  csi
[Panel]
 panel_verup  panel_recovup
[UserDataErase]
 UDEexec
[eMMC]
 emmc  emmc_wrv  emmc_wfchk
[serialFlashROM]
 sfr_showInfo
[SUBIF]
 nsclog
[CAPI]
 ct  mon
[WELL]
 nell-attach  nell-detach  nell-wakeup  nlog  up  down  stat  scan  join
 leave  wset  wget  wep  w12get  w12set  elog  uap  wfd
[TMN]
 tm  rt  w2t
[WPSE]
 wpse  wpscmd  wpsc
[AOSS]
 aoss
[WCF]
 wcf  wcfcmd
[ALINK]
 alinkcmd
[WP2PC]
 wp2pc
[IMS_Utility]
 imsconv  imsdump  imsprintmemout
[NFC]
 nfc_cmd
[WiFi]
 wprd  wtst
[Ethernet]


vers를 입력하면 모든 정보를 알려줍니다. DryOS 버전 2.3, release 59입니다.

디컴파일러로 펌웨어를 살펴보던 중, xdxm 같은 디버그 명령어가 제거된 것을 발견했습니다. 이 명령어들은 각각 메모리 덤프와 메모리 수정 기능을 담당합니다.


하지만 사실 완전히 사라진 건 아니었습니다. 단지 도움말에서 숨겨져 있을 뿐이었죠. IDA에서 usage: 문자열을 검색하면 또 다른 메모리 덤프 명령어를 찾을 수 있습니다. 나중에 익스플로잇 개발 과정에서 이 명령어들을 활용했습니다.


취약점 발견

먼저 공격 표면을 매핑했습니다. 프린터가 네트워크에 노출하는 항목은 다음과 같습니다.

웹 관리 인터페이스를 위한 HTTP/HTTPS, 인쇄를 위한 LPD·IPP·Jetdirect, 그리고 탐색을 위한 NetBIOS·SNMP·SLP·WSD·mDNS. 모두 표준 RFC 규격입니다. 그런데 맨 아래를 보면 캐논의 독자 프로토콜이 등장합니다. RFC도, 공개된 사양도 없이 캐논만의 방식을 고수하고 있습니다.


저희가 고려한 주요 공격 경로는 세 가지였습니다.

  1. RFC 프로토콜 구현상의 취약점입니다. 벤더들은 표준을 엉성하게 재구현하곤 하니까요.

  2. 파싱 버그입니다. 폰트, PDF, PostScript, PCL, 이미지 등 프린터가 복잡한 입력을 해석해야 하는 모든 지점이 대상입니다. 프린터는 신뢰할 수 없는 데이터를 파싱합니다.

  3. 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 in 10 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 in 10 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 in 10 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)
    goto free_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)
    goto free_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)
    goto free_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는 적절한 크기의 해제된 청크를 찾을 때까지 힙 청크를 하나씩 탐색하고, 찾지 못하면 최상위 청크로 되돌아갑니다. 이 동작이 예측 가능하다는 점을 이용하면, 버그를 유발하기 전에 원하는 레이아웃대로 할당을 배치할 수 있습니다.


아이디어는 간단합니다.

  1. 먼저 할당기가 최상위 청크에서 큰 청크를 할당하도록 유도합니다.

  2. 이를 해제한 뒤, 같은 영역에 더 작은 청크를 다시 할당합니다.

  3. 이렇게 하면 해제된 영역을 제어된 방식으로 분할하고 점유할 수 있습니다.

  4. 힙 레이아웃이 준비되면 버그를 유발합니다.


다음은 저희가 할당한 세 번째 청크의 실제 레이아웃입니다.

첫 번째 청크의 데이터 영역을 재사용하기 때문에, 첫 번째 청크에 스프레이한 값을 피해 청크의 Reserved 필드로 가져올 수 있습니다. 이 설정을 통해 대상 주소를 피해 청크의 Reserved 필드에 배치할 수 있습니다.


저희가 원하는 힙 레이아웃을 구축하는 데 도움이 되는 기능이 있습니다.

4208fc08    int32_t CPC_pjcc_dec_ope_echo(struct CPCResource* arg1, struct CPCUserDataInfo* user_buf, struct pjcc_echo_data…
...
4208fcd8
4208fc20        struct pjcc_echo_data* r0 = CPC_pjcc_zeroAlloc(8)
...
4208fcf4
4208fc30        uint32_t r0_2 = CPC_pjcc_return_param_len(user_buf) // --- [1]
4208fc38        r0->param_len.w = r0_2.w
...
4208fc44            r0_3, r3 = CPC_pjcc_zeroAlloc(r0_2) // --- [2]
4208fc4c            r0->param_buf = r0_3
4208fc50            int32_t r0_6
4208fc08    int32_t CPC_pjcc_dec_ope_echo(struct CPCResource* arg1, struct CPCUserDataInfo* user_buf, struct pjcc_echo_data…
...
4208fcd8
4208fc20        struct pjcc_echo_data* r0 = CPC_pjcc_zeroAlloc(8)
...
4208fcf4
4208fc30        uint32_t r0_2 = CPC_pjcc_return_param_len(user_buf) // --- [1]
4208fc38        r0->param_len.w = r0_2.w
...
4208fc44            r0_3, r3 = CPC_pjcc_zeroAlloc(r0_2) // --- [2]
4208fc4c            r0->param_buf = r0_3
4208fc50            int32_t r0_6
4208fc08    int32_t CPC_pjcc_dec_ope_echo(struct CPCResource* arg1, struct CPCUserDataInfo* user_buf, struct pjcc_echo_data…
...
4208fcd8
4208fc20        struct pjcc_echo_data* r0 = CPC_pjcc_zeroAlloc(8)
...
4208fcf4
4208fc30        uint32_t r0_2 = CPC_pjcc_return_param_len(user_buf) // --- [1]
4208fc38        r0->param_len.w = r0_2.w
...
4208fc44            r0_3, r3 = CPC_pjcc_zeroAlloc(r0_2) // --- [2]
4208fc4c            r0->param_buf = r0_3
4208fc50            int32_t r0_6

CPCA는 CADM이라는 관리자 기능을 제공합니다. CADM 안에서 유용했던 기능 중 하나가 에코(echo) 함수였습니다. 이 경로는 사용자가 직접 호출할 수 있고, 코드에서 두 개의 할당을 확인할 수 있습니다. [1] 메모리 할당 크기는 사용자가 보낸 2바이트 값으로 결정되고, [2] 저희가 보낸 데이터는 할당된 영역으로 그대로 복사됩니다. 따라서 요청 크기를 제어함으로써 필요한 큰 청크를 할당하고 그 영역에 데이터를 쓸 수 있었습니다.

이 방식으로 안정적인 임의 힙 해제(Arbitrary Heap Free)를 달성할 수 있었습니다. 임의 힙 해제를 확보한 다음 떠오르는 질문은 '그래서 어떤 주소를 해제할 것인가'입니다. CADM 함수를 분석하던 중, 펌웨어가 핸들러 테이블을 순회하며 일치하는 오퍼코드를 찾는다는 사실을 발견했습니다.

44a62710  struct pjcc_handlers pjcc_handler_list[0x28] =
44a62710  {
44a62710      [0x00] =
44a62710      {
44a62710          uint16_t operation_code = 0x6b
44a62712          uint16_t field_2 = 0x0
44a62714          struct attributeTable* field_4 = data_44a606b8
44a62718          uint32_t attribute_leng = 0xe
44a6271c          uint32_t (* decode_func)(...) = CPC_pjcc_dec_ope_jobStart2
44a62720          uint32_t (* encode_func)() = sub_41b65e88
44a62724          uint32_t (* release_func)() = sub_41b58bd0
44a62728          uint32_t (* field_14)() = sub_41b6894c
44a6272c          uint32_t field_1c = 0x0
44a62730          uint32_t field_20 = 0x0
44a62734      }
44a62734      [0x01] =
44a62734

44a62710  struct pjcc_handlers pjcc_handler_list[0x28] =
44a62710  {
44a62710      [0x00] =
44a62710      {
44a62710          uint16_t operation_code = 0x6b
44a62712          uint16_t field_2 = 0x0
44a62714          struct attributeTable* field_4 = data_44a606b8
44a62718          uint32_t attribute_leng = 0xe
44a6271c          uint32_t (* decode_func)(...) = CPC_pjcc_dec_ope_jobStart2
44a62720          uint32_t (* encode_func)() = sub_41b65e88
44a62724          uint32_t (* release_func)() = sub_41b58bd0
44a62728          uint32_t (* field_14)() = sub_41b6894c
44a6272c          uint32_t field_1c = 0x0
44a62730          uint32_t field_20 = 0x0
44a62734      }
44a62734      [0x01] =
44a62734

44a62710  struct pjcc_handlers pjcc_handler_list[0x28] =
44a62710  {
44a62710      [0x00] =
44a62710      {
44a62710          uint16_t operation_code = 0x6b
44a62712          uint16_t field_2 = 0x0
44a62714          struct attributeTable* field_4 = data_44a606b8
44a62718          uint32_t attribute_leng = 0xe
44a6271c          uint32_t (* decode_func)(...) = CPC_pjcc_dec_ope_jobStart2
44a62720          uint32_t (* encode_func)() = sub_41b65e88
44a62724          uint32_t (* release_func)() = sub_41b58bd0
44a62728          uint32_t (* field_14)() = sub_41b6894c
44a6272c          uint32_t field_1c = 0x0
44a62730          uint32_t field_20 = 0x0
44a62734      }
44a62734      [0x01] =
44a62734

펌웨어는 일치하는 항목을 찾으면 그 구조체에 저장된 핸들러 함수를 호출합니다. 호출자와 핸들러 모두 pjcc_ 접두사가 붙은 심볼을 사용하기 때문에, 이 테이블을 PJCC 핸들러 테이블이라 명명했습니다. 저희의 아이디어는 이 PJCC 핸들러 테이블을 해제하고 그 자리를 원하는 값으로 다시 채우는 것이었습니다.

그래서 테이블의 시작 주소를 해제한 뒤, 같은 자리에 저희만의 영역을 다시 할당해 가짜 PJCC 핸들러 테이블을 구성합니다. 그런 다음 CADM 에코 함수를 재사용해 그 영역을 원하는 데이터로 채우고, CPCA를 다시 전송합니다. 그러면 펌웨어는 저희가 만든 가짜 핸들러 테이블을 참조해, 임의 주소를 호출하게 됩니다.

이로써 PC 제어를 달성할 수 있습니다.


이 시점부터는 ROP 체인을 구축해야 했습니다. 저희는 다음 gadget을 사용했습니다.

201d       adds    r0, r4, #4 {var_40} # ------ [1]
fff740fd   bl      #sub_40bf1248

int32_t sub_40bf1248(int32_t* arg1)
8546       mov     sp, r0  # ----------- [2]
0d98       ldr     r0, [sp, #0x34] # ------- [3]
ddf838e0   ldr     lr, [sp, #0x38]
0f9b       ldr     r3, [sp, #0x3c]
5df8042b   pop     {r2}
20e90c00   stmdb   r0!, {r2, r3}
9de8fe1f   ldm     sp, {r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, r11, r12}
8546       mov     sp, r0
01bd       pop     {r0, pc} # -------- [4]

201d       adds    r0, r4, #4 {var_40} # ------ [1]
fff740fd   bl      #sub_40bf1248

int32_t sub_40bf1248(int32_t* arg1)
8546       mov     sp, r0  # ----------- [2]
0d98       ldr     r0, [sp, #0x34] # ------- [3]
ddf838e0   ldr     lr, [sp, #0x38]
0f9b       ldr     r3, [sp, #0x3c]
5df8042b   pop     {r2}
20e90c00   stmdb   r0!, {r2, r3}
9de8fe1f   ldm     sp, {r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, r11, r12}
8546       mov     sp, r0
01bd       pop     {r0, pc} # -------- [4]

201d       adds    r0, r4, #4 {var_40} # ------ [1]
fff740fd   bl      #sub_40bf1248

int32_t sub_40bf1248(int32_t* arg1)
8546       mov     sp, r0  # ----------- [2]
0d98       ldr     r0, [sp, #0x34] # ------- [3]
ddf838e0   ldr     lr, [sp, #0x38]
0f9b       ldr     r3, [sp, #0x3c]
5df8042b   pop     {r2}
20e90c00   stmdb   r0!, {r2, r3}
9de8fe1f   ldm     sp, {r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, r11, r12}
8546       mov     sp, r0
01bd       pop     {r0, pc} # -------- [4]

[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%의 성공률을 보였습니다. 실패하더라도 기기는 대개 오류 후 재부팅되었기 때문에 그냥 다시 시도하면 되었습니다.

다음은 데모 영상입니다:

https://www.youtube.com/shorts/8V2_tJ7siIs

이 취약점에는 CVE-2025-14233이 할당되었습니다.

패치 결과를 분석한 결과, 이 취약점이 다수의 Canon 기기에 영향을 미치는 것으로 확인되었습니다.

패치된 펌웨어가 출시된 직후, 곧바로 Patch Diffing을 수행했습니다.

패치가 문제를 제대로 해결했음을 확인했습니다. 이전 버전과 달리 더 이상 힙 버퍼를 순회하며 각 값을 잘못 해제하지 않고, 힙 버퍼 자체를 올바르게 한 번에 해제합니다.


다음은 실제 대회 현장에서 해킹한 장면입니다.

https://x.com/thezdi/status/1980641183845216486


결론

표준화된 프로토콜이라고 해서 그 구현까지 자동으로 안전해지는 것은 아닙니다. 명확히 정의된 RFC가 있더라도, 사소한 구현상의 실수 하나가 악용 가능한 취약점으로 이어질 수 있습니다. 또한 프린터는 그 특성과 아키텍처상 인증 전(pre-auth) 공격 경로가 많습니다. 공격자는 이렇게 노출된 공격 표면을 통해 다양한 버그를 악용할 수 있으므로 결코 가볍게 보아서는 안 됩니다.

DryOS는 악용 방지(mitigation) 기능이 상대적으로 제한적입니다. 이 때문에 단 하나의 메모리 손상 버그만으로도 공격자가 임의 코드 실행으로 이어지는 충분한 제어권을 확보할 수 있습니다. 게다가 프린터는 흔히 내부 네트워크에 연결되어 있으면서도 서버나 워크스테이션만큼 면밀하게 모니터링되지 않습니다. 그래서 프린터는 공격자에게 매력적인 은신처이자 진입점이 됩니다. 일단 침해되면 프린터는 네트워크 내부의 발판으로 쓰일 수 있습니다. 따라서 임베디드 기기는 단순한 주변 기기가 아니라 중요한 보안 경계로 다루어야 합니다.

PetoWorks는 바로 이러한 영역, 즉 펌웨어와 임베디드 기기, 독자 프로토콜처럼 일반적인 보안 점검이 닿기 어려운 공격 표면을 깊이 파고드는 공격적 보안 연구(Offensive Security Research)를 전문으로 합니다. 바이너리 익스플로잇부터 펌웨어 취약점 분석, 표면적인 점검만으로는 드러나지 않는 실질적 위협을 찾아내는 것이 저희의 일입니다. 관련 연구나 협업, 보안 점검이 필요하시다면 언제든 PetoWorks에 문의해 주시기 바랍니다.

다시 한번 이번 대회를 주최해 주신 Zero Day Initiative 팀에 감사드립니다. 그리고 신속히 대응하고 적절한 패치를 제공해 주신 Canon 벤더사에도 감사의 말씀을 전합니다.


‹ 이전 글

이전 글이 없습니다.

070-4110-1337 (대표번호)
02-861-1337 (세금계산서 문의)
02-861-1338 (Fax)

서울 금천구 가산디지털1로 205-15
SH드림타워 614호

(주) 글리치제로

함께 연구할 동료를 기다립니다

© PetoWorks Inc. 2025

070-4110-1337 (대표번호)
02-861-1337 (세금계산서 문의)
02-861-1338 (Fax)

서울 금천구 가산디지털1로 205-15
SH드림타워 614호

(주) 글리치제로

함께 연구할 동료를 기다립니다

© PetoWorks Inc. 2025

070-4110-1337 (대표번호)
02-861-1337 (세금계산서 문의)
02-861-1338 (Fax)

서울 금천구 가산디지털1로 205-15
SH드림타워 614호

(주) 글리치제로

함께 연구할 동료를

기다립니다

© PetoWorks Inc. 2025