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
  1. Step 1Read the source, confirm non-PIE
    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 64-bit ... not strippedwith no "PIE" in the output, which is what makes the symbol addresses fixed.
    bash
    cat app.py
    bash
    file spellbook   # expect 'ELF ... not stripped' and NO 'PIE'
    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 2Read all four function addresses from the binary
    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
    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 3Write the solve script
    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
    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.

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.

Want more picoCTF 2026 writeups?

Useful tools for General Skills

Related reading

What to try next