[ENG] Exploiting the Unexpected: A Pwn2Own-Level Break of a Printer

[ENG] Exploiting the Unexpected: A Pwn2Own-Level Break of a Printer


TL;DR

Team PetoWorks competed at Pwn2Own 2025 in Ireland and successfully exploited the Canon imageCLASS MF654Cdw printer, earning a $10,000 prize. The bug was assigned CVE-2025-14233, and in this post we'd like to share the technical details of our research.


Intro

For those unfamiliar with the event, Pwn2Own is something of a hacking olympics. It's a zero-day vulnerability competition hosted by Trend Micro's Zero Day Initiative, held three times a year — in January, May, and October. The host city and the set of targets change each time.

At the competition, participants demonstrate the exploits they've prepared. The target devices and software all run the latest firmware with every patch applied. You have to land your exploit on stage, in front of an audience, within a fixed time window — and if you succeed, you take home the prize. Every vulnerability is then responsibly disclosed to the vendor after the event.

The categories for the 2025 Ireland event were as follows:

  • Mobile Phones

  • Smart Home

  • Printers

  • Surveillance Systems

  • Home Automation Hub

  • Network Attached Storage

  • Messaging

  • Wearables

  • Small Office / Home Office (SOHO)


Our target was the Canon imageCLASS MF654Cdw from the Printers category, which runs on DryOS internally.

DryOS is an RTOS (Real-Time Operating System). The defining trait of an RTOS is deterministic execution: tasks run within strict timing constraints, scheduling is tighter, and interrupt handling is carefully managed.


Reconnaissance

Let's start with where the firmware actually lives. The bootloader sits in flash memory, while the firmware itself is stored on the eMMC. At boot, the bootloader loads the firmware from the eMMC into RAM and begins execution.


There's one interesting detail: the device exposes a UART debug interface.


Connecting over UART dropped us into a shell. Here's what we got:

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]


Typing vers tells us everything: DryOS version 2.3, release 59.

While poking through the firmware in the decompiler, we noticed that debug commands like xd and xm had been stripped out. These handle memory dump and memory modification, respectively.


But they hadn't actually been removed — they were merely hidden from the help listing. Searching for the string usage: in IDA surfaced another memory-dump command. We put these to use later during exploit development.


Finding the Vulnerability

We started by mapping the attack surface. Here's what the printer exposes to the network:

HTTP/HTTPS for the web management interface; LPD, IPP, and Jetdirect for printing; and NetBIOS, SNMP, SLP, WSD, and mDNS for discovery — all standard RFC protocols. But look at the bottom of the list, and Canon's proprietary protocol shows up: no RFC, no public spec, just Canon doing things the Canon way.


We considered three main attack avenues:

  1. Bugs in RFC protocol implementations. Vendors love to reimplement standards sloppily.

  2. Parsing bugs. Fonts, PDF, PostScript, PCL, images — anywhere the printer has to interpret complex input. A printer parses untrusted data for a living.

  3. Canon's proprietary custom protocols.


Looking back at Canon's RFC implementation bugs over the past few years, the pattern is consistent: every time a vendor reimplements a standard protocol, it breaks.

- CVE-2022-3942 : LLMNR (RFC 4795)
- CVE-2022-24673 : SLP (RFC 2608)
- CVE-2023-0853 : mDNS name compression (RFC 1035)
- CVE-2023-0854 : NetBIOS name
-

- CVE-2022-3942 : LLMNR (RFC 4795)
- CVE-2022-24673 : SLP (RFC 2608)
- CVE-2023-0853 : mDNS name compression (RFC 1035)
- CVE-2023-0854 : NetBIOS name
-

- CVE-2022-3942 : LLMNR (RFC 4795)
- CVE-2022-24673 : SLP (RFC 2608)
- CVE-2023-0853 : mDNS name compression (RFC 1035)
- CVE-2023-0854 : NetBIOS name
-


Then there's parsing. The print data path is a perfect attack surface — untrusted input, parsing logic, and decades of accumulated, complex legacy formats all in one place.

- CVE-2024-12649 : TrueType fonts Buffer overflow in XPS data font processing
- CVE-2025-14232 : XPS Parsing Buffer Overflow
- CVE-2025-14235 : TrueType fonts parsing Out-Of-Bounds Write
-

- CVE-2024-12649 : TrueType fonts Buffer overflow in XPS data font processing
- CVE-2025-14232 : XPS Parsing Buffer Overflow
- CVE-2025-14235 : TrueType fonts parsing Out-Of-Bounds Write
-

- CVE-2024-12649 : TrueType fonts Buffer overflow in XPS data font processing
- CVE-2025-14232 : XPS Parsing Buffer Overflow
- CVE-2025-14235 : TrueType fonts parsing Out-Of-Bounds Write
-


We applied a mix of vulnerability-analysis techniques: static analysis, auditing, and fuzzing.


In the end, the bug was triggered through fuzzing. When the crash hit, the task-abort message was printed straight to the UART console — a full dump of the registers, the task name, PC, PSR, and more.

Dry> < Error Exception >

Dry> < Error Exception >

Dry> < Error Exception >


The vulnerability lived in a feature of the CPCA (Common Peripheral Controlling Architecture) protocol. CPCA is Canon's proprietary protocol, and it controls nearly every function of the MFP (multifunction printer) — printing, copying, scanning, mailbox management, and more.

Through reverse engineering, we worked out CPCA's wire format. It's a 20-byte header — magic bytes 0xCDCA, version, response code, opcode, payload length, a few reserved fields, and padding — followed by a variable-length payload.


Here are a few of the operation codes; there are dozens in total. The dispatcher is a handler table, where each handler carries an operation code, a length, and function pointers for decode, encode, and release.

Opcodes

Function

0x01

Echo

0x0c

Add user

0x0d

Delete user

0x0e

Change user password

0x2b

Shutdown

0x49

Create file

0x50

Check user password

0x5f

Delete Files

The opcode where we found our vulnerability is 0x5f (Delete File).

Here's the bug:

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] In the allocation flow, the deleteFiles handler first allocates a struct, then inside it allocates a single contiguous buffer called file_ids of size 4 * file_count. One contiguous buffer — a perfectly ordinary pattern. [2] It then iterates over each file_id element and calls free on the address of each one.

But file_ids is a single buffer, not an array of individually allocated pointers. The only value valid to pass to free() is the base address. You can't free element 1, 2, or 3 separately — you have to free the whole thing at once. So on this error path, the code ends up calling free on pointers that were never allocated. Every iteration produces an invalid free.

Here's the situation illustrated:

On the left is the intended behavior: a single free(file_ids). On the right is what actually happens: three invalid free calls — three attempts to free memory that was never individually allocated. These are invalid frees, and they corrupt the heap.

But here's where it gets really interesting. If we can control the contents of file_ids, and the free logic treats each element as a pointer, then we get to control the address passed to free. In other words, we can perform an arbitrary address free.


This is a bug the compiler simply cannot catch. The pointer passed to free() is type-correct — a void* — so there's no syntax error and no type violation; from the compiler's point of view, nothing is wrong. It compiles cleanly and only blows up at runtime, with a free(): invalid pointer crash.

Static analysis misses it for the same reason. This bug isn't syntactically wrong — it's semantically wrong. The intent the code expresses (free one contiguous buffer in its entirety) and what it actually does (free each element as if it were an individually allocated pointer) diverge, and that kind of semantic-level error doesn't surface from type checking or syntax checking alone.


Exploitation

Before getting into the bug itself, let's lay out the memory model we'll be attacking. DryOS heap chunks are aligned to 16 bytes, and each chunk contains a tag field, a size field, a next pointer, and a reserved field. The key was that reserved field. It isn't used by the heap manager itself — it exists purely to maintain alignment.

So how does this bug actually behave?

The bug walks the values inside the object sequentially and frees them one by one, because it treats the array address plus an offset of 0x10 as the start of another heap chunk. And this is where the real problem arises.

If you free an invalid address — even something as obviously bad as 0 — DryOS raises an error immediately. So we needed a way to write a controlled value into that field to avoid the error, which was critical for the reliability of the exploit.

DryOS walks the heap chunks one at a time until it finds a freed chunk of the right size, and if it can't find one, it falls back to the top chunk. Because this behavior is predictable, we can exploit it to lay out our allocations exactly the way we need before triggering the bug.


The idea is simple:

  1. First, force the allocator to carve a large chunk out of the top chunk.

  2. Free it, then re-allocate a smaller chunk in the same region.

  3. This lets us split and occupy the freed region in a controlled way.

  4. Once the heap layout is ready, trigger the bug.


Here's the actual layout of the third chunk we allocated:

Because it reuses the data region of the first chunk, we can steer the values we sprayed in the first chunk into the Reserved field of this chunk. With this setup, we can place our target address into the Reserved field of that chunk.


There's a feature that helps us build the heap layout we want.

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 provides a manager feature called CADM. One of the useful functions within CADM was the echo function. This path is user-callable, and you can see two allocations in the code. [1] The allocation size is determined by a 2-byte value the user sends, and [2] the data we send is copied directly into the allocated region. So by controlling the request size, we could allocate the large chunk we needed and write our data into that region.

With this approach, we can achieve a reliable arbitrary heap free.

With an arbitrary heap free in hand, the next question is: which address do we free? While analyzing the CADM functions, we found that the firmware walks a handler table looking for a matching opcode.

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

When the firmware finds a matching entry, it calls the handler function stored in that struct. Since both the caller and the handlers use symbols prefixed with pjcc_, we named this the PJCC handler table. Our idea was to free this PJCC handler table and refill the spot with values of our choosing.

So we free the table's starting address, then re-allocate our own region in the same spot to build a fake PJCC handler table. We then reuse the CADM echo function to fill that region with the data we want, and send the CPCA request again. The firmware then consults our fake handler table and calls an arbitrary address.

With this, we achieve PC control.


From here we needed to build a ROP chain. We used the following 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] Here, the first instruction adds 4 to the R4 register and stores the result in R0. [2] And, the instruction moves the value of the R0 register into the stack pointer register. [3] And, it fetches data based on stack pointer register and loads it into each register. [4] And, the epilogue follows. We believe this code gadget is used during task switching. So this gadget allows us to set most of the registers and the stack based on the address stored in R4.

Using this gadget, we got one step closer to remote control.

Even after gaining PC control, we couldn't jump straight into shellcode. DryOS appears to enforce memory permissions per region: every region we had write access to was non-executable, so any attempt to jump there caused a prefetch abort in DryOS. Fortunately, prior research by the Neodyme team showed us how to get around this.

The Canon printer we targeted uses an ARM CPU. On ARM, MMU access is largely governed by domains. There are 16 domains in total, each with a 2-bit field defining its access permissions, and these domains are configured through the Domain Access Control Register (DACR). Setting that register to manager mode bypasses memory access checks. So before jumping into our injected shellcode, we first jump to a gadget that sets the DACR to manager mode, and only then jump to the shellcode.

For the Pwn2Own demo, we needed proof-of-concept code. Since this is an RTOS, we made it modify the display and play a sound to prove impact.

To do that, we wrote shellcode that modifies the framebuffer. The device has an 800×480 display and requires 3 bytes of RGB data per pixel, so the shellcode receives roughly 1 MB of image data over the network and writes it to the framebuffer to alter the screen. It then calls the function that activates the image sensor so that the frame we wrote is shown on the display.

With every obstacle in the exploit resolved, we were ready to assemble the full chain. In testing, the chain achieved roughly a 90% success rate. Even on failure, the device would usually just error out and reboot, so we simply retried.

Here's the demo video:

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

This vulnerability was assigned CVE-2025-14233.

Analyzing the patch, we confirmed that the vulnerability affects a wide range of Canon devices.

As soon as the patched firmware was released, we ran patch diffing.

We confirmed the patch properly addressed the issue. Unlike the previous version, it no longer walks the heap buffer freeing each value incorrectly — instead, it now correctly frees the heap buffer itself, in one go.


Here's a shot from the actual competition floor where we landed the hack.

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


Conclusion

A standardized protocol doesn't automatically make its implementation safe. Even with a clearly defined RFC, a small implementation mistake can turn into an exploitable vulnerability. On top of that, printers — by their nature and architecture — expose many pre-authentication attack paths. Attackers can abuse a variety of bugs through these exposed surfaces, so they shouldn't be taken lightly.

DryOS has relatively limited exploit mitigations. Because of this, a single memory-corruption bug can be enough for an attacker to gain the control needed for arbitrary code execution. And printers are often connected to internal networks while being monitored far less closely than servers or workstations. That makes them an appealing hidden entry point for attackers — once compromised, a printer can serve as a foothold deeper inside the network. Embedded devices should be treated not as mere peripherals, but as a critical security boundary.

This Pwn2Own was a valuable opportunity for us at PetoWorks to validate the research capabilities we've built up on a global stage — and to share the results like this. PetoWorks specializes in exactly this kind of work: offensive security research that digs deep into attack surfaces ordinary security reviews rarely reach, such as firmware, embedded devices, and proprietary protocols. From binary exploitation to firmware vulnerability analysis, our job is to find the real threats that surface-level reviews never reveal. If you need research, collaboration, or a security assessment, reach out to PetoWorks anytime.

Finally, our thanks once again to the Zero Day Initiative team for hosting the event, and to Canon for responding quickly and providing an appropriate patch.

070-4110-1337
(Representative Number)


02-861-1337
(Invoice Inquiry)


02-861-1338 (Fax)

205-15 Gasan Digital 1-ro,
Geumcheon-gu, Seoul
Room 614, SH Dream Tower

We are waiting for
colleagues to research together

© PetoWorks Inc. 2025

070-4110-1337
(Representative Number)


02-861-1337
(Invoice Inquiry)


02-861-1338 (Fax)

205-15 Gasan Digital 1-ro,
Geumcheon-gu, Seoul
Room 614, SH Dream Tower

We are waiting for
colleagues to research together

© PetoWorks Inc. 2025

070-4110-1337
(Representative Number)


02-861-1337
(Invoice Inquiry)


02-861-1338 (Fax)

205-15 Gasan Digital 1-ro,
Geumcheon-gu, Seoul
Room 614, SH Dream Tower

We are waiting for colleagues to research together

© PetoWorks Inc. 2025