bytemancy 3 picoCTF 2026 Solution

Published: March 20, 2026

Description

Can you conjure the right bytes? Download app.py and the compiled spellbook binary, then reconstruct the required payload.

Download app.py and the compiled spellbook binary.

Read app.py and analyse the binary - both are needed to understand the full validation.

bash
cat app.py
bash
file spellbook
bash
chmod +x spellbook

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1
    Read the source, confirm non-PIE
    Observation
    I noticed app.py asks for a raw 4-byte address that must match the same function location across all three rounds, which suggested the binary must have fixed symbol addresses and therefore needed to be confirmed as non-PIE before any address extraction.
    app.py asks for the raw 4-byte little-endian address of one of four named functions, for 3 random rounds: ember_sigil, glyph_conflux, astral_spark, binding_word. file spellbook should print ELF 32-bit ... not stripped with no "PIE" in the output, which is what makes the symbol addresses fixed.
    bash
    cat app.py
    bash
    file spellbook   # expect 'ELF 32-bit ... not stripped' and NO 'PIE'

    Expected output

    spellbook: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, not stripped
    What didn't work first

    Tried: Assume the binary is PIE and try to calculate addresses at runtime by leaking the base via /proc/self/maps or a format string.

    Because PIE randomizes the load base, that approach is needed for PIE binaries - but spellbook is non-PIE (ET_EXEC), so its symbol addresses are fixed absolute values baked in at link time. Running 'file spellbook' or 'checksec --file=spellbook' will show the absence of 'PIE' in the output, confirming static addresses can be read directly from the symbol table.

    Tried: Run 'readelf -s spellbook' but look only at the DYNAMIC symbol table (.dynsym) and miss the challenge functions.

    The four target functions (ember_sigil, glyph_conflux, astral_spark, binding_word) are in the regular symbol table (.symtab), not the dynamic symbol table (.dynsym). readelf -s shows both sections, but if you filter only on DYNAMIC or use nm --dynamic, the locally-defined functions will not appear. Use 'nm spellbook' or 'readelf -s spellbook' and look for all FUNC entries.

    Learn more

    This challenge introduces function addresses in binary files. Every named function in a compiled ELF or PE binary has a fixed address determined at link time (for non-PIE binaries). This address is stored in the binary's symbol table - a data structure that maps function names to their memory addresses.

    The server asks for the address in little-endian 4-byte format. Little-endian means the least significant byte comes first. For example, if a function is at address 0x08048420, the 4-byte little-endian representation is \x20\x84\x04\x08. This byte order is used by x86 and x64 processors, and is why exploit payloads in binary exploitation always write addresses in this format.

    The concept of sending a raw function address connects directly to return-oriented programming (ROP) and buffer overflow exploitation, where an attacker overwrites a saved return address on the stack with the address of a desired function or gadget. Mastering address formats and byte ordering is a fundamental prerequisite for binary exploitation.

  2. Step 2
    Read all four function addresses from the binary
    Observation
    I noticed the binary was confirmed as non-PIE and not stripped, which meant pwntools ELF could resolve all four function names (ember_sigil, glyph_conflux, astral_spark, binding_word) directly from the symbol table and pack each address into the required 4-byte little-endian format with p32().
    Use pwntools' ELF to read the symbol table. If elf.symbols['ember_sigil'] raises KeyError (binary is stripped), fall back to harvesting addresses in Ghidra or radare2.
    python
    python3 - <<'EOF'
    from pwn import ELF, p32
    
    elf = ELF("./spellbook", checksec=False)
    funcs = ["ember_sigil", "glyph_conflux", "astral_spark", "binding_word"]
    try:
        addrs = {name: elf.symbols[name] for name in funcs}
    except KeyError as e:
        raise SystemExit(f"Symbol missing ({e}); binary is stripped, recover addresses in Ghidra/r2 manually.")
    
    for name, addr in addrs.items():
        # p32() is little-endian 4-byte pack: 0x08048420 -> b'\x20\x84\x04\x08'
        print(f"{name}: {hex(addr)} -> {p32(addr).hex()}")
    EOF
    What didn't work first

    Tried: Use struct.pack('>I', addr) (big-endian) instead of p32(addr) or struct.pack('<I', addr) to encode the address.

    The server expects 4-byte little-endian encoding, which matches x86's native byte order. Big-endian reverses the bytes, so address 0x08048420 becomes b'\x08\x04\x84\x20' instead of b'\x20\x84\x04\x08'. The server's unpack call uses '<I' (little-endian), so the comparison fails silently with no helpful error. Always use p32() or struct.pack('<I', addr) for x86 addresses.

    Tried: Use objdump -d spellbook and manually read the hex address from the disassembly header line for each function.

    objdump -d shows the virtual address of each function in the disassembly listing (e.g. '08048420 <ember_sigil>:'), so this can technically work, but it is error-prone when manually copying hex values across four functions. pwntools ELF.symbols gives the integer directly without parsing, and a single typo in one hex digit produces a wrong payload that fails only on the round that function is chosen.

    Learn more

    pwntools is the standard CTF binary exploitation library for Python. Its ELF class parses an ELF binary and provides convenient access to symbols (elf.symbols['name']), GOT/PLT addresses, sections, and more. The p32(addr) function packs a 32-bit integer into 4 bytes in little-endian order - equivalent to struct.pack('<I', addr).

    A PIE (Position Independent Executable) binary has all addresses randomized by ASLR at load time - its symbol addresses in the ELF file are relative offsets from the base. A non-PIE binary has fixed absolute addresses, making it possible to precompute function locations exactly. You can check with checksec --file=binary or by inspecting the ELF header's e_type field (ET_EXEC for non-PIE, ET_DYN for PIE).

    The four function names in this challenge (ember_sigil, glyph_conflux, astral_spark, binding_word) are custom symbols added by the challenge author. In real binaries, stripped of debug symbols, function names are unavailable - recovery requires heuristic analysis (function prologue patterns, cross-reference analysis) in tools like Ghidra, which can partially reconstruct symbol names from library call patterns.

  3. Step 3
    Write the solve script
    Observation
    I noticed the server runs three rounds with a randomly chosen function name each time, which suggested pre-computing all four addresses offline and then using r.send (not sendline) to deliver exactly 4 raw bytes per round without a trailing newline.
    Connect, read each round's prompt, match the requested function name, and send its 4-byte little-endian address. Use r.send (not sendline) - the protocol expects exactly 4 raw bytes; an extra '\n' makes it 5 and fails. Print one received line first to confirm the quote style before relying on the in check.
    python
    python3 - <<'EOF'
    from pwn import *
    
    HOST, PORT = "<HOST>", <PORT_FROM_INSTANCE>
    
    elf = ELF("./spellbook", checksec=False)
    funcs = ["ember_sigil", "glyph_conflux", "astral_spark", "binding_word"]
    addrs = {name: elf.symbols[name] for name in funcs}
    
    r = remote(HOST, PORT)
    r.recvuntil(b"unlock the flag.")  # banner
    
    for _ in range(3):
        line = r.recvuntil(b"==> ", timeout=5).decode()
        print("DEBUG round prompt:", repr(line))   # confirm quote style on first run
        # The parsing is brittle: if app.py prints "ember_sigil" without quotes,
        # adjust the substring test (e.g., drop the quotes).
        for name in funcs:
            if f"'{name}'" in line:
                r.send(p32(addrs[name]))   # send, NOT sendline - exact 4 bytes
                break
    
    print(r.recvall(timeout=3).decode())
    EOF
    What didn't work first

    Tried: Use r.sendline(p32(addrs[name])) instead of r.send(p32(addrs[name])) to send the address.

    sendline appends a '\n' byte, making the payload 5 bytes instead of 4. The server reads exactly 4 bytes and interprets the 5th byte as the start of the next round's input, causing all subsequent rounds to desync. The connection produces garbled output or a premature failure with no clear error. The protocol is binary, not line-oriented, so r.send with exactly 4 bytes is required.

    Tried: Parse the prompt with a split on spaces or newlines and index a specific token to extract the function name, instead of using substring search.

    If app.py's output format changes (different quote style, extra whitespace, or a colon appended), a positional split breaks silently and the name variable never matches, causing the script to send nothing for that round and receive a wrong-answer response. The substring test 'if name in line' or 'if f"\'{name}\'" in line' is more robust because it does not depend on token position. The DEBUG print at the start of the script exists precisely to reveal the actual quote style before committing to the parsing logic.

    Learn more

    The recvuntil(delimiter) method reads bytes from the socket until the delimiter appears, then returns everything including the delimiter. This is the standard way to synchronize with a server that sends multiple prompts - you wait for a known marker before sending your response, ensuring correct alignment in the conversation.

    The r.send(payload) call (without line) sends raw bytes without a newline. This is important here because the server expects exactly 4 raw bytes - adding a newline would make it 5 bytes and fail the comparison. sendline() adds \n automatically and is appropriate for text-based protocols, while send() is for binary protocols where exact byte counts matter.

    Iterating over 3 random rounds with the correct function each time demonstrates the power of pre-computation: you extract all four addresses once before connecting, then look them up by name during the interactive session. This pattern appears in time-limited challenges where the server demands a correct answer within milliseconds - you cannot afford to analyze the binary after connecting, so all analysis happens offline beforehand.

Interactive tools
  • Base64 & Base32 DecoderDecode Base64 and Base32 strings with auto-detection. Multi-layer mode unwraps nested encodings automatically.
  • Recipe ChainStack decoders into a pipeline: Base64, hex, ROT, XOR, Morse, URL, Atbash, Vigenère, and more. Magic mode auto-discovers the chain. Bookmark the URL to save it.
  • Number Base ConverterConvert numbers between binary, octal, decimal, and hexadecimal instantly. Enter any value and see all four bases update in real time.

Flag

Reveal flag

picoCTF{byt3m4ncy_3_...}

app.py randomly selects 3 of 4 functions (ember_sigil, glyph_conflux, astral_spark, binding_word) from the spellbook binary and asks for their raw 4-byte little-endian addresses. Use pwntools ELF to read symbol addresses and send them per round.

Key takeaway

Non-PIE ELF binaries embed absolute function addresses in their symbol tables, making every function's memory location knowable before the program ever runs. This is the same property that traditional buffer overflow and return-to-libc exploits depend on: if an attacker can read the binary, they can precompute where to redirect execution. Position-independent executables combined with ASLR were introduced precisely to randomize those addresses at load time, raising the bar to a two-stage leak-then-exploit approach. Reading symbol tables with tools like pwntools ELF is the first step in virtually every binary exploitation workflow.

Related reading

Want more picoCTF 2026 writeups?

Useful tools for General Skills

What to try next