
isoforge: Fuzzing ISO 8583 Payment Messages Against a Mock Switch
Card payments still run on ISO 8583. It is a binary message format from the 1980s. A bitmap says which fields are present. Some fields are fixed length and some carry their own length. EMV chip data sits inside field 55 as BER-TLV. Most parsing bugs in this space come from trusting a length or a bitmap bit.
The real switches and terminals that speak this format are closed and expensive. Nobody should test against them without permission. isoforge is a small open lab where you can build, parse, break, and replay these messages against a mock switch on your own machine, and see how a parser should fail safely.
v1 is done. Tickets T1 through T7 are merged: scaffold and CI, spec and codec, DE55 TLV parser, mock switch, send client, fuzzer and tighter TLV checks, and the compose lab. main is at 7ccc8f5. 63 tests pass. CI runs the tests, CodeQL, Scorecard, and the compose lab, and it is green. The numbers below come from the CI lab run on 7ccc8f5 and a local rerun of the same commit.
What is in the box
Python 3.12. click for the CLI. pyiso8583 for the wire format. A small BER-TLV parser written in the repo. pytest. docker compose. No Java, no jPOS, no database.
A fixture JSON file goes into the CLI. The CLI can build and parse through the codec, walk field 55 through the TLV parser, or mutate a message and send it. The send path is TCP with a 2-byte length header, and it only talks to 127.0.0.1 unless you opt out. The mock switch answers 0800 with 0810, 0200 with 0210, and 0420 with 0430. The client writes a masked jsonl log.
The CLI commands are isoforge switch, isoforge send, and isoforge fuzz. build and parse are library functions, not commands.
The codec lives in spec.py and codec.py. The field spec is the MTI, the primary bitmap, and data elements 2, 3, 4, 7, 11, 12, 13, 39, 41, 49, and 55. build takes a dict and returns bytes. parse takes bytes and returns a dict. It refuses anything over 8192 bytes before it parses. Every failure comes out as one CodecError. A raw library exception never leaves the boundary.
The DE55 parser in tlv.py and tags.py is a flat BER-TLV walk. It handles multi-byte tags and the 0x81 and 0x82 long-form lengths. It rejects indefinite length (0x80), lengths that run past the buffer, tags longer than 3 bytes, and a DE55 value over 255 bytes. Every failure raises TLVError with the byte offset. It names 13 common EMV tags: 9F26 ARQC, 9F27, 9F10, 95 TVR, 9A, 9C, 9F02 amount, 5F2A currency, 82 AIP, 84 AID, 9F37 unpredictable number, 9F36 ATC, and 9F1A terminal country.
The mock switch in switch.py is an asyncio TCP server bound to 127.0.0.1. It reads frames with a 2-byte length prefix and caps them at 8 KB. It answers 0800 with 0810. It answers 0200 with 0210, DE39 00 for the allowed test PANs and 14 otherwise. It answers 0420 with 0430. Bad input gets DE39 30 or a closed socket. One bad client never takes the server down. isoforge switch has no host option, so it cannot be bound anywhere but 127.0.0.1.
The fuzzer in fuzz.py has four mutators: bitmap flip, length overflow, field truncation, and bad TLV length. The RNG is seeded, so the same seed always gives the same cases. It records how the switch reacts (reply code, closed socket, or timeout) and prints a summary. A crash means the switch stops answering. T6 also tightened the TLV parser. It now rejects overlong length encodings and duplicate tags, length-checks fixed-width EMV tags, and keeps the TLV offset in the CodecError message.
One authorization, end to end
fixtures/0200.json holds an authorization request. MTI 0200. DE2 is the test PAN 4111111111111111. DE3 is the processing code. DE4 is the amount. DE7, DE11, DE12, and DE13 are the time and the trace. DE41 is the terminal id. DE49 is currency 360, which is IDR. DE55 is the chip data as hex, with 11 EMV tags.
build turns that dict into bytes: the MTI, an 8-byte bitmap with one bit per present field, then the fields in order. DE2 and DE55 carry their own length prefixes, LLVAR and LLLVAR.
The client adds a 2-byte length header and sends the frame to the switch on 127.0.0.1. The switch reads the header, refuses anything over 8 KB, and parses the frame. It walks DE55 into named tags, checks the PAN against the allowed list, and builds a 0210 that echoes DE11 and DE41 with DE39 00. The client parses the 0210 and writes one jsonl line with the PAN masked to 411111******1111.
In CI, docker compose up -d --wait and then isoforge send fixtures/0200.json printed 0210 DE39=00. A local run against isoforge switch printed the same line.
What broke while I built it
The parse library leaks raw Python errors. Passing a str or None to parse, or a non-dict to build, raised TypeError. A None or an int in field 55 raised TypeError. A key like "²" raised ValueError, because the library calls int() on anything that looks numeric. The fix was one rule: nothing but CodecError ever leaves parse or build. A wrapper is only a boundary if it owns every error.
Secondary bitmaps are the other quiet trap. If the first bit of the bitmap is set, a second 8-byte bitmap follows. v1 supports only the primary bitmap, so it rejects the secondary one on purpose, with a clear message, instead of a confusing "field 1 not defined". A short input with that bit set fails as truncated, which is the more accurate error.
TLV length honesty came next. An early build wrote whatever length the caller declared, so a 1-byte length on a 3-byte value produced malformed TLV. A mismatch now raises TLVError. The fuzzer breaks lengths by mutating bytes after the build, so the builder never has to produce bad data.
The realistic fixture had more tags than I had planned. A real ARQC request carries 9F37, 9F36, and 9F1A. I kept them and added their names instead of trimming the fixture. A test fails if any fixture tag shows up as "unknown".
Before the switch existed I fuzzed the parsers with random input. 30,000 random inputs to parse and 50,000 to tlv.parse raised no unexpected exception types.
Then the structured run. isoforge fuzz --seed 1 -n 500 against the compose lab in CI: 500 cases and 0 crashes. The mutators split as bitmap flip 121, length overflow 123, field truncation 130, and bad TLV length 126. The switch answered 475 times with DE39 30 (format error), closed the socket 3 times, and answered 22 times with DE39 00. A local rerun on the same commit gave the same split, so the seed is reproducible. After the run, the switch still answered a normal 0200.
Those 22 approvals are the interesting part. Some mutations still produce a well-formed message the switch accepts. That is expected for a lab switch that only checks the PAN list, and it is the point of the next step, which is ARQC checks. I did not measure which mutator produced those 22, or the DE39 30 versus closed-socket split per mutator. The summary counts reactions only in total.
Hardening while building the TLV parser: tlv.build accepted malformed tags. 1F and 9F80 built bytes that parsed back as a different tag. An empty tag built 0100. The 255-byte cap was only checked on parse. All of these now raise TLVError, with round-trip tests.
What is not done: no second-model review of T4 through T7 yet, and no coverage-guided fuzzing.
The safety line
The only trust boundary is the CLI and the mock switch over TCP. Both sides treat every byte as untrusted. There is an 8 KB cap. Errors become a response code. There is no crash and no eval.
The switch binds 127.0.0.1 only, and a test asserts it never binds 0.0.0.0. Compose publishes only to 127.0.0.1. The client refuses any host that is not loopback unless you pass --i-own-this-target. That flag is an explicit opt-in, and the README warns against using it on anything you do not own.
There is no real card data. Fixtures use Luhn-valid test PANs, 4111111111111111 and 5555555555554444, and a test fails the build if any other PAN appears. Logs mask the PAN to the first 6 and last 4 digits.
v1 has no secrets. No keys, no PIN blocks, no MAC, no ARQC crypto. Dependencies are pinned. CI runs pip-audit, CodeQL, and Scorecard.
This is for learning, and for testing systems you own. Do not point it at a real host.
After v1
The next pieces are the secondary bitmap and the full 128-field spec. Then ARQC verification against a software HSM stand-in, with test keys only, so the switch can approve or decline on the cryptogram instead of the PAN list. Then a MAC on DE64 and DE128, and PIN block handling for ISO 9564 format 0, again with test keys. Then mTLS between the client and the switch, to mirror how a real acquirer link runs. Then coverage-guided fuzzing with atheris, next to the structure-aware mutators that exist now. And dialect profiles, ASCII lengths versus BCD lengths, because every network bends ISO 8583 a little differently.
Resources
- Repository: https://github.com/frdrckj/isoforge
mainat7ccc8f5
FJerusha