Description
Can you conjure the right bytes? Download app.py and the compiled spellbook binary, then reconstruct the required payload.
Setup
cat app.pyfile spellbookchmod +x spellbookSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1Read the source, confirm non-PIE
Observationapp.py wants a raw 4-byte address matching the same function location across three rounds, which only works if the symbol addresses are fixed. Confirm the binary is non-PIE before extracting anything.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 i386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, BuildID[sha1]=0028c839fc5f43b51c9230d87125c038fdc9c6ce, for GNU/Linux 3.2.0, with debug_info, 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.
That is what a PIE binary needs, because PIE randomizes the load base. This one is ET_EXEC, with absolute addresses fixed at link time. file or checksec shows no PIE in the output, which means the symbol table can be read directly.
Tried: Run 'readelf -s spellbook' but look only at the DYNAMIC symbol table (.dynsym) and miss the challenge functions.
The four target functions live in the regular symbol table, not the dynamic one. readelf -s shows both, but filtering on dynamic symbols hides every locally defined function. List all of them and look for the 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 2Read all four function addresses from the binary
ObservationThe binary is non-PIE and not stripped, so pwntools can resolve all four function names straight from the symbol table and pack each address little-endian 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()}") EOFExpected output
ember_sigil: 0x8049176 -> 76910408 glyph_conflux: 0x804919a -> 9a910408 astral_spark: 0x80491c1 -> c1910408 binding_word: 0x80491e3 -> e3910408
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 unpacks little-endian, matching x86's native order. Big-endian reverses the bytes, so the address arrives backwards and the comparison fails with no useful error. p32(), or an explicit little-endian struct.pack, is what you want for x86 addresses.
Tried: Use objdump -d spellbook and manually read the hex address from the disassembly header line for each function.
objdump does print each function's virtual address in the disassembly header, so it works, but copying four hex values by hand invites a typo. pwntools gives the integer without parsing, and one wrong digit produces a payload that fails only on the round that picks that function.
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 3Write the solve script
ObservationThe server picks a random function name each of three rounds. Precompute all four addresses, then send exactly four raw bytes per round with no 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. app.py does try to swallow a trailing newline, but only if it has already arrived when it checks, so send() is the deterministic choice. 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 newline, so five bytes go out where the server reads four. app.py's read_exact_bytes() peeks for a trailing newline and drops it, but only when that byte is already readable at that instant, so whether the round survives depends on TCP timing. When the peek loses the race the stray byte becomes the first byte of the next round's address and everything after is out of step. The protocol is binary rather than line-oriented, so send exactly four bytes.
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.
Change the quote style, add whitespace, append a colon, and a positional split breaks silently: the name never matches, the script sends nothing, and the round comes back wrong. A substring test does not depend on token position. The debug print at the top of the script exists to show you the real format before you commit to parsing it.
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{0bjdump_m4g1c_...}
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.