Scratch Space

  • Category: rev
  • Difficulty: Beginner
  • Author: hoover

“The program maps a scratch page, builds the real passphrase there, compares it against your guess with a loop of its own, then zeroes the page and unmaps it before printing anything. strings, ltrace and strace all come back empty.”

Third in the series, and it closes each of the previous doors on purpose: nothing on disk for strings, no libc compare for ltrace, no file for strace. What it doesn’t hide is the generator.

Solution:

1. Why the tracers come back empty

main maps one anonymous RW page and builds the passphrase into it:

4011f6:  mov    r8d,0xffffffff       ; fd = -1
4011fc:  mov    ecx,0x22             ; MAP_PRIVATE|MAP_ANONYMOUS
401201:  mov    edx,0x3              ; PROT_READ|PROT_WRITE
401206:  mov    esi,0x1000
401210:  call   401040 <mmap@plt>

The comparison is a hand-written byte loop rather than strcmp, so no library call ever carries the plaintext:

401290:  movzx  edx,BYTE PTR [rax+rdx*1]     ; input[i]
4012a8:  movzx  eax,BYTE PTR [rax]           ; page[i]
4012ab:  cmp    dl,al
4012af:  mov    DWORD PTR [rbp-0x14],0x0     ; mismatch -> result = 0

and the page is wiped, then unmapped, before either verdict is printed:

4012c9:  mov    BYTE PTR [rax],0x0           ; zero 0x2a+1 bytes
4012ec:  call   401060 <munmap@plt>
4012f1:  cmp    DWORD PTR [rbp-0x14],0x0     ; only now decide what to print
2. The generator is a static LCG

The page contents are not random. Seed and ciphertext are both fixed, and the blob sits at 0x404040:

40116e:  mov    DWORD PTR [rbp-0x1c],0x311023df   ; seed
401225:  imul   eax,eax,0x343fd                   ; state = state*0x343fd + 0x269ec3
40122b:  add    eax,0x269ec3
401244:  shr    eax,0x10                          ; keystream byte = state >> 16
401253:  xor    ecx,esi
40125d:  cmp    DWORD PTR [rbp-0x18],0x29         ; 42 bytes

Nothing in that loop depends on the input, the clock or the environment, so the page can be rebuilt offline rather than raced in a debugger:

$ ./solve.py ./scratch-space
NNS{s34rch3d_7h3_mm4p_b3f0r3_17_w4s_w1p3d}

Full script: solve.py

3. Confirm against the binary

Worth feeding back, since it proves the reconstruction matches the page the program actually built rather than just decoding to something flag-shaped:

$ echo 'NNS{s34rch3d_7h3_mm4p_b3f0r3_17_w4s_w1p3d}' | ./scratch-space
guess: checking...
correct
4. The intended dynamic route

Reading it out of memory also works, but the breakpoint has to be chosen with the wipe in mind. The order is build → print checking... → compare → wipemunmap → verdict, so breaking on munmap@plt is already too late: the page is intact but zeroed. The last useful stop is the head of the wipe loop at 0x4012c0, with the page pointer in [rbp-0x8]:

$ echo AAAA | gdb -q -batch -ex 'break *0x4012c0' -ex run \
      -ex 'x/s *(char**)($rbp-8)' ./scratch-space
Breakpoint 1, 0x00000000004012c0 in main ()
0x7ffff7f84000:	"NNS{s34rch3d_7h3_mm4p_b3f0r3_17_w4s_w1p3d}"

Under pwndbg, telescope $rbp-8 at the same address shows the pointer and the string behind it in one step.

Flag: NNS{s34rch3d_7h3_mm4p_b3f0r3_17_w4s_w1p3d}

Takeaways

  • “Wipes the buffer before exiting” only defeats a breakpoint placed after the wipe. Locate the wipe loop first and break at its head — that is the latest point where the secret is still fully materialised.
  • Anti-tracing that hides the plaintext (private page, no libc compare, scrubbed on exit) does nothing about the generator. If the seed and ciphertext are static, the runtime defences are irrelevant and the whole thing reduces to replaying a keystream offline.