Remit
An accounts-payable portal where two solid bugs fall out quickly — and the final step is genuinely dead on the installed runtime. Worth writing up because it doesn’t finish.
Spoiler-light notes — no flags, credentials or copy-paste exploits. Solve it yourself first.
Recon
Two vhosts on one host: a supplier-facing portal and a separate reviewer console. The supplier signup is open, so the first job is just to register and start poking at the invoice workflow — which begins with a spreadsheet upload.
Bug one: filter bypass by encoding
Invoice uploads are parsed server-side: the application pulls an XML part out of the submitted spreadsheet and parses it with a parser configured to load a DTD and resolve external entities. That’s a classic XML external entity injection, and the only thing standing in front of it was a byte-level filter looking for the ASCII spellings of the DOCTYPE and ENTITY keywords.
That filter is trivially defeated by encoding. Declaring the document as UTF-16 and writing it out with a byte-order mark meant the parser auto-detected the encoding and processed the inline DTD as normal — while the filter, scanning bytes, saw none of the ASCII sequences it was looking for.
The general lesson outranks the specific payload: a filter that inspects raw bytes is not a check on what the parser will actually see. Any time a defensive control operates at a different layer from the component doing the work, look for an encoding, normalisation or representation gap between them.
The result was arbitrary local file read as the web user. Swapping the entity’s URL scheme for an encoding filter let me pull the application’s own configuration and source without triggering parse errors — which is the important escalation here. XXE is an information disclosure bug, but the information is the source code, and that changes the next step entirely.
Bug two: an allow-list that includes the wrong field
Reading the profile-update handler from source showed the classic mass-assignment pattern:
the code loops over every submitted field and writes the ones that appear in an allow-list.
The allow-list is the fix — except this one contains role.
So a supplier could update their own role. One request, and the same credentials now authenticated against the reviewer console, with access to the whole invoice review queue.
This is worth calling out as a pattern rather than a one-off: allow-lists fail in exactly one way — they contain one field too many. When you read an update handler, the allow-list is the first thing to check, and any field that maps to authorisation is an instant finding.
The blocker: a gadget that the runtime killed
The intended finish was remote code execution via a deserialization gadget chain, using an object-destruction method found in the codebase that performed an arbitrary file write. The delivery mechanism was a URL wrapper that the upload parser would resolve, which would trigger the destructor.
It does not work on the installed version. The current major release of the runtime has removed that wrapper-triggered deserialization path entirely, so the destructor never fires. I confirmed every delivery route I could think of was dead — no persistent upload path, the progress-tracking upload feature disabled, log output properly escaped — and the gadget would only fire on a version several majors older.
So the box ends here: two confirmed bugs, complete source read, reviewer-level access, and a documented blocker on the final hop. No flag.
Dead ends (worth recording)
Also ruled out and confirmed negative: SQL injection in the console’s sort/filter parameters
(properly whitelisted), XSS in the queue rows (correctly HTML-escaped), environment recovery
from the web worker’s /proc entry (the server strips worker environments), gopher and
dict schemes through the entity resolver (not supported by the XML library), and a
classic reverse-proxy path-normalisation trick (not vulnerable here).
Takeaways
- Byte-level filters do not describe what the parser sees. Encoding, BOMs and normalisation are the gap between the two — and that gap is the bug.
- XXE’s real prize is usually source code, not
/etc/passwd. Read it, then let the code tell you what the next bug is. - In any update handler, the allow-list is the finding. A privileged field on the list is mass assignment.
- Encoding a payload isn’t just for WAFs — it’s how you slip past a control written at the wrong layer.
- Document the blocker. A writeup that ends “and here’s the thing that is genuinely patched in this version, with everything I tried” is more useful than a fabricated ending.
- Version matters. Gadget chains and deserialization vectors die when runtimes change — check the installed version before investing hours in a payload.