← writeups

// writeup

Practical Binary Analysis, Ch. 5: Basic Binary Analysis in Linux

20 July 2026 · binary-analysis · ctf · reverse-engineering · linux

Notes on Chapter 5 of Practical Binary Analysis by Dennis Andriesse. This chapter is different from the first four: instead of explaining tools one by one, it runs a Capture the Flag challenge and introduces each tool at the moment you need it. You start with a file called payload and no hints, and you finish with a flag.

Everything below is the author’s actual challenge, from the code archive he publishes at practicalbinaryanalysis.com (pba-code.tar.gz, 6.9 MB). I ran all of it inside WSL2, since the book’s own advice is not to run unknown binaries outside a VM. These binaries are 2018 vintage, so two of them needed coaxing to run on Ubuntu 22.04 at all.

the whole toolbox
file <f>          # what is this? (magic bytes, not extensions)
file -z <f>       # look inside compressed files without extracting
xxd <f>           # hex + ASCII dump;  -s seek  -l length  -b binary  -i C array
dd if= of= bs=1 skip= count=       # carve bytes out of a file
readelf -h / -s   # parse ELF headers and symbol tables
nm -D --demangle  # list symbols; -D reads .dynsym when the file is stripped
c++filt <sym>     # demangle one C++ name
strings <f>       # printable runs;  -d data sections only  -n min length
ldd <f>           # shared-library dependencies (runs the binary, VM only)
strace <cmd>      # system calls
ltrace -i -C      # library calls;  -i show IP  -C demangle
objdump -d / -s   # disassemble / dump raw section contents
gdb               # break, run, info registers, x/s

Peeling the payload

file does not trust extensions; it reads magic bytes. That makes it the right first move on an unknown blob.

what is this thing?
$ file payload
payload: ASCII text

$ head -n 3 payload
H4sIABzY61gAA+xaD3RTVZq/Sf+lFJIof1r+2aenKKh0klJKi4MmJaUvWrTSFlgR0jRN20iadpKX
UljXgROKjbUOKuOfWWfFnTlzZs/ZXTln9nTRcTHYERhnZ5c/R2RGV1lFTAFH/DNYoZD9vvvubd57
bcBl1ln3bL6e9Hvf9+733e/+v+/en0dqId80WYAWLVqI3LpooUXJgUpKFy6yEOsCy6KSRQtLLQsW

Only A–Z a–z 0–9 + /, in neat fixed-width rows. That alphabet is Base64. Decoding it starts a chain that goes four layers deep.

down the layers
$ base64 -d payload > decoded_payload
$ file decoded_payload
decoded_payload: gzip compressed data, last modified: Mon Apr 10 19:08:12 2017, from Unix

$ file -z decoded_payload
decoded_payload: POSIX tar archive (GNU) (gzip compressed data,)

$ tar xvzf decoded_payload
ctf
67b8601

$ file ctf
ctf: ELF 64-bit LSB executable, x86-64, dynamically linked,
     interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32,
     BuildID[sha1]=29aeb60bcee44b50d1db3a56911bd1de93cd2030, stripped

$ file 67b8601
67b8601: PC bitmap, Windows 3.x format, 512 x 512 x 24

file -z is the nice trick here: it reports what is inside the gzip layer without extracting anything, so you learn it is a tar archive before committing to unpacking it.

payload, ASCII text alphabet is A–Z a–z 0–9 + / → Base64 decoded_payload, gzip file: gzip compressed data, from Unix POSIX tar archive (GNU) found with file -z, without extracting ctf ELF 64-bit LSB executable dynamically linked stripped needs a library that does not exist on the system 67b8601 PC bitmap, 512×512 24 bpp · valid header lib5ae9b7f.so from byte 52 10,296 bytes BMP declares its pixels at 54: the ELF starts 2 early THE TOOL THAT OPENS IT $ base64 -d payload > decoded_payload head shows the alphabet; base64 decodes it $ file -z decoded_payload peeks through the gzip layer without extracting $ tar xvzf decoded_payload unzips and unpacks in one step $ grep 'ELF' * "binary file 67b8601 matches": an ELF header inside something claiming to be a bitmap $ xxd 67b8601 | head finds 7f 45 4c 46 at offset 0x34 = 52 $ dd skip=52 count=64 bs=1 … grabs the 64-byte header first $ readelf -h elf_header e_shoff 8568 · e_shnum 27 · e_shentsize 64 size = 8568 + (27 × 64) = 10296 …which is exactly the library's real size
Nothing here is encrypted, just wrapped. Each layer announces itself to file except the last: a shared library spliced into a bitmap, which an image viewer renders without complaint. Only grep finding an ELF header where no ELF should be gives it away.

ldd: a dependency that does not exist

it will not even start
$ ./ctf
./ctf: error while loading shared libraries: lib5ae9b7f.so:
       cannot open shared object file: No such file or directory

$ ldd ctf
        linux-vdso.so.1 (0x00007819b6692000)
        lib5ae9b7f.so => not found
        libstdc++.so.6 => /lib/x86_64-linux-gnu/libstdc++.so.6 (0x00007819b6400000)
        libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007819b6664000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007819b6000000)
        libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007819b6319000)
        /lib64/ld-linux-x86-64.so.2 (0x00007819b6694000)

Exactly one dependency is missing, and its name is not something any distribution ships. So it has to be in the files I already have. From Chapter 2 I know every ELF starts with \x7fELF, which makes grep a workable search:

grep for an ELF header
$ grep 'ELF' payload decoded_payload ctf 67b8601
grep: ctf: binary file matches
grep: 67b8601: binary file matches

ctf matching is expected. 67b8601 matching is not. That file claims to be a bitmap.

xxd and dd: carving the library out

xxd 67b8601 | head
00000000: 424d 3800 0c00 0000 0000 3600 0000 2800  BM8.......6...(.
00000010: 0000 0002 0000 0002 0000 0100 1800 0000  ................
00000020: 0000 0200 0c00 c01e 0000 c01e 0000 0000  ................
00000030: 0000 0000 7f45 4c46 0201 0100 0000 0000  .....ELF........
00000040: 0000 0000 0300 3e00 0100 0000 7009 0000  ......>.....p...
00000050: 0000 0000 4000 0000 0000 0000 7821 0000  ....@.......x!..

BM at offset 0 is a genuine bitmap header. The ELF magic sits at offset 0x34 = 52. Finding where it ends is harder, because ELF has no end marker. The chapter’s trick is to extract the 64-byte header first and let the header tell you the size.

header first, then the arithmetic
$ dd skip=52 count=64 if=67b8601 of=elf_header bs=1
64+0 records in
64+0 records out

$ xxd elf_header
00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000  .ELF............
00000010: 0300 3e00 0100 0000 7009 0000 0000 0000  ..>.....p.......
00000020: 4000 0000 0000 0000 7821 0000 0000 0000  @.......x!......
00000030: 0000 0000 4000 3800 0700 4000 1b00 1a00  ....@.8...@.....

$ readelf -h elf_header
  Type:                              DYN (Shared object file)
  Entry point address:               0x970
  Start of section headers:          8568 (bytes into file)
  Size of section headers:           64 (bytes)
  Number of section headers:         27
readelf: Error: Reading 0x6c0 bytes extends past end of file for section headers

readelf complains the tables are past the end of the file, which is true and harmless: I only extracted 64 bytes. The three numbers I need survive. Since the section header table is the last thing in an ELF:

size = e_shoff + (e_shnum × e_shentsize)
     = 8568   + (27 × 64)
     = 10296
carve the whole library
$ dd skip=52 count=10296 if=67b8601 of=lib5ae9b7f.so bs=1
10296+0 records in
10296+0 records out
10296 bytes (10 kB, 10 KiB) copied

$ file lib5ae9b7f.so
lib5ae9b7f.so: ELF 64-bit LSB shared object, x86-64, dynamically linked,
               BuildID[sha1]=5279389c64af3477ccfdf6d3293e404fd9933817, stripped

nm: reading stripped C++ symbols

nm needs -D on a stripped file
$ nm lib5ae9b7f.so
nm: lib5ae9b7f.so: no symbols

$ nm -D --demangle lib5ae9b7f.so
0000000000000c60 T rc4_decrypt(rc4_state_t*, unsigned char*, int)
0000000000000c70 T rc4_decrypt(rc4_state_t*, std::__cxx11::basic_string<…>&)
0000000000000b40 T rc4_encrypt(rc4_state_t*, unsigned char*, int)
0000000000000bc0 T rc4_encrypt(rc4_state_t*, std::__cxx11::basic_string<…>&)
0000000000000cb0 T rc4_init(rc4_state_t*, unsigned char*, int)
                 U std::__throw_logic_error(char const*)@GLIBCXX_3.4
                 U malloc@GLIBC_2.2.5
                 U memcpy@GLIBC_2.14

$ c++filt _Z8rc4_initP11rc4_state_tPhi
rc4_init(rc4_state_t*, unsigned char*, int)

Plain nm fails because the file is stripped. That is Chapter 1’s lesson again: .symtab gone, .dynsym retained. -D reads the dynamic table instead, and --demangle turns the mangled names into signatures.

With the library in place the binary runs, after telling the loader where to look:

LD_LIBRARY_PATH
$ export LD_LIBRARY_PATH=`pwd`
$ ./ctf ; echo "exit=$?"
exit=1

strings: cheap hints

strings ctf: the interesting ones
DEBUG: argv[1] = %s
checking '%s'
show_me_the_flag
flag = %s
guess again!
It's kinda like Louisiana. Or Dagobah. Dagobah - Where Yoda lives!

A command-line argument is expected, something gets checked, and there is a suspiciously specific literal to try. The Dagobah line is a candidate RC4 key.

guessing the argument
$ ./ctf foobar
checking 'foobar'
exit=1

$ ./ctf show_me_the_flag
checking 'show_me_the_flag'
ok
exit=1

The check passes, the exit status is still 1, and strings has nothing left to offer.

strace and ltrace

strace shows system calls. For this binary it is almost all process setup, and the application’s entire contribution is two writes and an exit. The chapter is upfront that this one is a dead end. ltrace traces library calls instead, and that changes everything:

ltrace -i -C ./ctf show_me_the_flag
[0x400fe9] __libc_start_main(0x400bc0, 2, 0x7ffea84d7578, 0x4010c0 <unfinished ...>
[0x400c44] __printf_chk(1, 0x401158, 0x7ffea84d9137, 256)          = 28
[0x400c51] strcmp("show_me_the_flag", "show_me_the_flag")          = 0
[0x400cf0] puts("ok")                                              = 3
[0x400d07] rc4_init(rc4_state_t*, unsigned char*, int)(…, 0x4011c0, 66, 3183) = 0
[0x400d14] std::__cxx11::basic_string<…>::assign(char const*)(…, 0x40117b, 58, 3)
[0x400d29] rc4_decrypt(rc4_state_t*, std::__cxx11::basic_string<…>&)(…)
[0x400d36] std::__cxx11::basic_string<…>::_M_assign(…)             = 0
[0x400d53] getenv("GUESSME")                                       = nil
[0xffffffffffffffff] +++ exited (status 1) +++

The whole plot in nine lines. The argument is checked with strcmp. The Dagobah string is the RC4 key, 66 bytes, passed straight to rc4_init. A 58-byte string is assigned and decrypted, and then getenv is called and returns nil. The program wants an environment variable whose name was encrypted inside the binary.

so set it
$ GUESSME=foobar ./ctf show_me_the_flag
checking 'show_me_the_flag'
ok
guess again!

Tracing again with the variable set shows a second decryption and then puts("guess again!") with no comparison call in between. A secret is decrypted and compared to my input by code that calls nothing, so ltrace is blind to it. The next tool has to be a disassembler.

objdump: finding the comparison

objdump -s locates "guess again!" at 0x4011af in .rodata. Disassembling around the code that loads it exposes the loop:

objdump -M intel -d ctf
400dc0:  0f b6 14 03    movzx  edx,BYTE PTR [rbx+rax*1]  ; load your byte
400dc4:  84 d2          test   dl,dl                     ; NUL -> too short
400dc6:  74 05          je     400dcd                    ; ran out -> fail
400dc8:  3a 14 01       cmp    dl,BYTE PTR [rcx+rax*1]   ; your byte vs truth byte
400dcb:  74 13          je     400de0                    ; match -> keep going
400dcd:  bf af 11 40 00 mov    edi,0x4011af              ; "guess again!"
400dd2:  e8 d9 fc ff ff call   400ab0 <puts@plt>
400de0:  48 83 c0 01    add    rax,0x1                   ; i++
400de4:  48 83 f8 15    cmp    rax,0x15                  ; all 21 bytes done?
400de8:  75 d6          jne    400dc0                    ; loop

rbx is my string, rcx is the ground truth, rax is the index, and 0x15 = 21 is the expected length. The comparison at 0x400dc8 reads from a buffer that only exists after runtime decryption, so no amount of static analysis will show its contents.

the four values that matter rbx → your GUESSME string rcx → the decrypted ground truth rax → loop index i 0x15 → expected length (21) 400dc0: movzx edx,BYTE PTR [rbx+rax*1] load your byte 400dc4: test dl,dl NUL = too short 400dc6: je 400dcd ran out → fail 400dc8: cmp dl,BYTE PTR [rcx+rax*1] ← break here 400dcb: je 400de0 match → keep going 400dcd: mov edi,0x4011af "guess again!" 400dd2: call 400ab0 <puts@plt> 400de0: add rax,0x1 i++ 400de4: cmp rax,0x15 all 21 bytes done? 400de8: jne 400dc0 loop back failure path puts("guess again!") success path · 400dea rc4_decrypt → printf("flag = %s") why ltrace could not see this It is a hand-written byte loop, not strcmp. No library call = no ltrace line. And rcx points at a buffer that only exists after runtime decryption, so strings and objdump cannot reach it. (gdb) break *0x400dc8 (gdb) x/s $rcx "Crackers Don't Matter" static analysis narrows it to one instruction; dynamic analysis reads the answer out of a register encrypt a comparison and only the runtime knows the truth
The check that ltrace is blind to. Once objdump locates the compare at 0x400dc8, a single gdb breakpoint and x/s $rcx dumps the value the program is comparing against.

gdb: reading the answer out of a register

break on the compare, dump rcx
$ gdb -q ./ctf
Reading symbols from ./ctf...
(No debugging symbols found in ./ctf)
(gdb) break *0x400dc8
Breakpoint 1 at 0x400dc8
(gdb) set env GUESSME=foobar
(gdb) run show_me_the_flag
checking 'show_me_the_flag'
ok

Breakpoint 1, 0x0000000000400dc8 in ?? ()
(gdb) info registers rcx
rcx            0x615ee0            6381280
(gdb) info registers rax
rax            0x0                 0
(gdb) x/s $rcx
0x615ee0:       "Crackers Don't Matter"

rax is 0, so this is the first iteration and rcx points at the start of the string. And 21 characters is exactly the 0x15 the loop compares against.

the flag
$ GUESSME="Crackers Don't Matter" ./ctf show_me_the_flag
checking 'show_me_the_flag'
ok
flag = 84b34c124b2ba5ca224af8e33b077e9e

Exercise: the levels the oracle unlocks

A New CTF Challenge. Complete the new CTF challenge unlocked by the oracle program!

feeding the flag to the oracle
$ ./oracle 84b34c124b2ba5ca224af8e33b077e9e
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+
| Level 1 completed, unlocked lvl2         |
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+
Run oracle with -h to show a hint

Level 2: “Combine the parts”

lvl2 is a stripped ELF that links only libc and imports srand, time, rand and puts. Running it prints a different two-hex-digit value every second:

it emits one piece at a time
$ for i in $(seq 1 6); do ./lvl2; sleep 1.05; done
74
d6
f8
df
74
d6

Sampling it repeatedly gives you the set of parts but never their order, and a flag is an ordered 32-hex string. So this is a static problem rather than a dynamic one. The whole program is 14 instructions:

objdump -M intel -d lvl2
400500:  sub    rsp,0x8
400504:  xor    edi,edi
400506:  call   4004d0 <time@plt>
40050b:  mov    edi,eax
40050d:  call   4004c0 <srand@plt>          ; srand(time(NULL))
400512:  call   4004e0 <rand@plt>
400518:  shr    edx,0x1c                    ;
40051d:  and    eax,0xf                     ; rand() % 16
400524:  mov    rdi,QWORD PTR [rax*8+0x601060]  ; <- table of 16 pointers
40052c:  call   4004a0 <puts@plt>

There is a table of 16 pointers at 0x601060, and its order is the answer. Dumping both the table and what it points at:

objdump -s: the table and the strings
Contents of section .data:
 601060 c4064000 00000000 c7064000 00000000   -> 0x4006c4, 0x4006c7
 601070 ca064000 00000000 cd064000 00000000   -> 0x4006ca, 0x4006cd
 ...                                          (16 entries, 3 bytes apart)

Contents of section .rodata:
 4006c0 01000200 30330034 66006334 00663600  ....03.4f.c4.f6.
 4006d0 61350033 36006632 00626600 37340066  a5.36.f2.bf.74.f
 4006e0 38006436 00643300 38310036 63006466  8.d6.d3.81.6c.df
 4006f0 00383800                             .88.

Walking the pointers in table order gives 03 4f c4 f6 a5 36 f2 bf 74 f8 d6 d3 81 6c df 88:

level 2 solved
$ ./oracle 034fc4f6a536f2bf74f8d6d3816cdf88
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+
| Level 2 completed, unlocked lvl3         |
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+

Level 3: “Fix four broken things”

This one is a pure Chapter 2 exam. lvl3 will not run at all, and file reports nonsense:

a deliberately corrupted ELF
$ file lvl3
lvl3: ELF 64-bit LSB executable, Motorola Coldfire, version 1 (Novell Modesto),
      can't read elf program headers at 4022250974, …, stripped

$ ./lvl3
bash: ./lvl3: cannot execute binary file: Exec format error

$ xxd -l 64 lvl3
00000000: 7f45 4c46 0201 010b 0000 0000 0000 0000  .ELF............
00000010: 0200 3400 0100 0000 d005 4000 0000 0000  ..4.......@.....
00000020: dead beef 0000 0000 8011 0000 0000 0000  ................
00000030: 0000 0000 4000 3800 0900 4000 1d00 1c00  ....@.8...@.....

Three breaks are visible in that dump, and the fourth is one section header deeper:

#FieldBroken valueShould be
1e_ident[EI_OSABI] @ 0x070x0b (Novell Modesto)0x00 (System V)
2e_machine @ 0x120x0034 (EM_COLDFIRE)0x003e (EM_X86_64)
3e_phoff @ 0x200xdeadbeef0x40
4.text sh_typeSHT_NOBITSSHT_PROGBITS

The fourth only shows up once the first three are fixed and readelf -S will parse: every other code section is PROGBITS, but .text claims to occupy no file space at all. .text is at section index 14, so its header is at e_shoff + 14×64 = 0x1180 + 0x380 = 0x1500, and sh_type sits at +4.

patch all four, then run it
d = bytearray(open('lvl3','rb').read())
d[0x07]        = 0x00                          # EI_OSABI  -> System V
d[0x12:0x14]   = (0x3e).to_bytes(2,'little')   # e_machine -> EM_X86_64
d[0x20:0x28]   = (0x40).to_bytes(8,'little')   # e_phoff   -> 0x40
d[0x1504:0x1508] = (1).to_bytes(4,'little')    # .text     -> SHT_PROGBITS
open('lvl3_repaired','wb').write(d)
level 3 solved
$ ./oracle 3a5c381e40d2fffd95ba4452a0fb4a40
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+
| Level 3 completed, unlocked lvl4         |
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+

$ ./oracle 3a5c381e40d2fffd95ba4452a0fb4a40 -h
Watch closely while I run

lvl4 is unlocked and its hint points straight back at strace/ltrace/gdb. That is where I stopped, since the exercise asked for the level the flag unlocks and levels 2 and 3 are both done.

Summary

The chapter’s real argument is that the standard toolbox composes. Nothing used here is specialised: file, head, base64, tar, grep, xxd, dd, readelf, nm, c++filt, strings, ldd, strace, ltrace, objdump, gdb. Every one ships with a stock Linux install, and the flag falls out of using them in the right order.

The two moments that actually mattered were both handoffs. ltrace showing getenv("GUESSME") turned an unknown program into a known question, and objdump narrowing the check to 0x400dc8 turned that question into a single gdb breakpoint. The bonus levels then invert the lesson: level 2 looks dynamic and is solved statically, level 3 is solved entirely with Chapter 2’s knowledge of the ELF header. Chapter 6 moves on to disassembly proper.