Description
Can you conjure the right bytes? Download app.py and the compiled spellbook binary, then reconstruct the required payload.
Setup
Download app.py and the compiled spellbook binary.
Read app.py and analyse the binary - both are needed to understand the full validation.
cat app.pyfile spellbookchmod +x spellbookSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1
Read the source, confirm non-PIEObservationI 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 spellbookshould printELF 32-bit ... not strippedwith no "PIE" in the output, which is what makes the symbol addresses fixed.bashcat app.pybashfile 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.
Step 2
Read all four function addresses from the binaryObservationI 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'ELFto read the symbol table. Ifelf.symbols['ember_sigil']raisesKeyError(binary is stripped), fall back to harvesting addresses in Ghidra or radare2.pythonpython3 - <<'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()}") EOFWhat 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
ELFclass parses an ELF binary and provides convenient access to symbols (elf.symbols['name']), GOT/PLT addresses, sections, and more. Thep32(addr)function packs a 32-bit integer into 4 bytes in little-endian order - equivalent tostruct.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=binaryor by inspecting the ELF header'se_typefield (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.
Step 3
Write the solve scriptObservationI 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.pythonpython3 - <<'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()) EOFWhat 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 (withoutline) 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\nautomatically and is appropriate for text-based protocols, whilesend()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.