Marlboro - Forensics
Points: 499 | Flag: BITSCTF{d4mn_y0ur_r34lly_w3n7_7h47_d33p} | Solved by: Smothy @ 0xN1umb

what we got
a zip called SaveMeFromThisHell.zip with one file inside: Marlboro.jpg. challenge description talking about smoke, fire, secrets, and some "programming language from hell where code mutates and sanity is optional". 500 points. aight lets go.

sick VW Golf with the Marlboro livery ngl. but we're not here for the car.
the solve
layer 1: the image has a secret
first thing i always do - check for appended data after the JPEG:
data = open('Marlboro.jpg', 'rb').read()
idx = data.rfind(b'\xff\xd9') # JPEG end marker
print(f'Data after JPEG: {len(data) - idx - 2} bytes')
# Data after JPEG: 17082 bytes17KB hidden after the JPEG. classic. file says its a zip with extra data prepended - thats our polyglot JPEG/ZIP.
$ unzip Marlboro.jpg
inflating: extracted/smoke.png
extracting: extracted/encrypted.bintwo files: a dark smoke.png and a mysterious encrypted.bin.
layer 2: steganography in the smoke
checked exiftool on smoke.png and immediately spotted something:
Author: aHR0cHM6Ly96YjMubWUvbWFsYm9sZ2UtdG9vbHMv
base64 decode:
https://zb3.me/malbolge-tools/
Malbolge. the programming language literally designed to be as difficult to use as possible. named after the eighth circle of hell in Dante's Inferno. thats what the challenge description meant by "programming language from hell."
then ran zsteg on the png:
$ zsteg smoke.png
b1,rgb,lsb,xy .. text: "# Marlboro Decryption Key\n# Format: 32-byte XOR
key in hexadecimal\nKEY=c7027f5fdeb20dc7308ad4a6999a8a3e069cb5c8111d5690
4641cd344593b657\n# Usage: XOR each byte of encrypted.bin with key[i % 32]"LSB steganography hiding a decryption key in the RGB channels. we got the key and the instructions.
layer 3: decrypting the transmission
key_hex = 'c7027f5fdeb20dc7308ad4a6999a8a3e069cb5c8111d56904641cd344593b657'
key = bytes.fromhex(key_hex)
data = open('encrypted.bin', 'rb').read()
decrypted = bytes([data[i] ^ key[i % len(key)] for i in range(len(data))])output:
Content-Type: text/x-malbolge
Interpreter: https://zb3.me/malbolge-tools/#interpreter
Language: Malbolge
D'`$##\!<5|jWVT/eRcONp_oJJHHF!3gfBc!?a_{)(xwYon4rqpi/mlkdibaf_%]ba
`_XWVz=YRQPOsSRQ3ONGkKD,BAe(DCBA@?>7[5432V6v.3,P*).-&Jk)"!&}C#cy~wv
<zyxZpotm3qSi/gOkd*KJ`ed]\"`Y^]\[TxRQVOTSLpPONMLEiIH*)?>bB$@987[Z:
38765.Rsr*/.-&%$#G'~f$#z@~}|{t:[wvonm3kSonmlkdihg`&GFbaZ~^@VUTYQRQP
tNMLKPImGFKJCBf)E>bB;@?>7[5432Vw/43,POp.'&+*#G!~D1
Malbolge source code. with an explicit pointer to the zb3.me interpreter. this is gonna be important.

layer 4: the malbolge interpreter rabbit hole (the hard part)
so this is where it got absolutely insane. i wrote a standard Malbolge interpreter based on the Ben Olmstead reference implementation and ran the code. it produced 40 bytes of complete garbage:
00614a4b0c660c7f90185f573a36626f7b73731d0f64646c343d1f...
not a flag. not anything. tried multiple implementations, all same garbage output.
spent way too long debugging this until i realized - the challenge SPECIFICALLY points to zb3.me/malbolge-tools/. so i pulled the actual JavaScript VM source from that site and compared it to the standard reference implementation.
the critical difference: the zb3 interpreter uses a different encryption table (xlat2) for the self-modifying code step, while the standard reference implementation uses xlat1.
standard Malbolge:
// after executing an instruction, encrypt it with xlat1:
mem[c] = xlat1[mem[c] - 33];zb3 implementation:
// after executing, encrypt with xlat2 (DIFFERENT table!):
vm.mem[vm.c] = xlat2.charCodeAt(vm.mem[vm.c] - 33);xlat2 from zb3:
5z]&gqtyfr$(we4{WP)H-Zn,[%\3dL+Q;>U!pJS72FhOA1CB6v^=I_0/8|jsb9m<.TVac`uY*MK'X~xDl}REokN:#?G"i@
there's also a difference in xlat1 itself (position 75: Y vs H) and the code pointer advancement behavior after jumps. the zb3 interpreter always advances the code pointer, even after a jmp instruction.
once i used the EXACT zb3 VM implementation:
var xlat1 = '+b(29e*j1VMEKLyC})8&m#~W>qxdRp0wkrUo[D7,XTcA"lI.v%{gJh4G\\-=O@5`_3i<?Z\';FNQuY]szf$!BS/|t:Pn6^Ha';
var xlat2 = '5z]&gqtyfr$(we4{WP)H-Zn,[%\\3dL+Q;>U!pJS72FhOA1CB6v^=I_0/8|jsb9m<.TVac`uY*MK\'X~xDl}REokN:#?G"i@';
// ... exact zb3 step function with xlat2 encryption ...output:
BITSCTF{d4mn_y0ur_r34lly_w3n7_7h47_d33p}

the full chain
Marlboro.jpg
└─ appended ZIP (polyglot)
├─ smoke.png
│ ├─ Author metadata → base64 → https://zb3.me/malbolge-tools/
│ └─ LSB steganography → XOR decryption key
└─ encrypted.bin
└─ XOR decrypt → Malbolge source code
└─ run with zb3 interpreter (NOT standard!) → flag
what i learned
- Malbolge has multiple interpreter implementations and they are NOT all equivalent. the encryption table used after instruction execution (
xlat1vsxlat2) completely changes the program output - when a challenge explicitly points to a specific interpreter, USE THAT EXACT ONE
- "programming language from hell" is not just flavor text lmao
- the flag says it all:
d4mn_y0ur_r34lly_w3n7_7h47_d33p- yeah we really did go that deep
flag
BITSCTF{d4mn_y0ur_r34lly_w3n7_7h47_d33p}

4 layers deep into a forensics challenge at 3am debugging differences between Malbolge interpreter implementations. this is what peak CTF performance looks like fr
smothy out ✌️