This is a toy program written for this lesson. It is not one of the
challenges, and its rule is different from all of them. We pretend we have
never seen its source; the source is at the end. Every output below was
captured from real runs. Where we cut lines, we say so.
The program is toylock-stripped. It asks for a password.
Step 1: look
$ file toylock-stripped
toylock-stripped: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=074a0d112cebca200cfecfc122f14d0e531efdc5, for GNU/Linux 3.2.0, stripped
64-bit Linux program (ELF is the Linux program file format),
executable (not PIE, so fixed addresses), stripped (no function
names).
$ strings toylock-stripped (excerpt: the lines that look like messages)
Password:
Door open.
Wrong.
The messages are there; the password is not. It is probably encoded.
$ echo abcdefgh | ltrace ./toylock-stripped
printf("Password: ") = 10
fgets("abcdefgh\n", 64, 0x70b90aa608e0) = 0x7ffcf25abfc0
strcspn("abcdefgh\n", "\n") = 8
strlen("abcdefgh") = 8
memcmp(0x7ffcf25abfa8, 0x402010, 8, 16) = 15
puts("Wrong.") = 7
Password: Wrong.
+++ exited (status 0) +++
Each line is one library call: the function, its arguments, and after =
what it returned. Clues:
fgets reads our line; strcspn finds the newline so it can be removed.
strlen measures our input. Maybe the length is checked.
memcmp compares 8 bytes at two addresses and returned 15 (not 0, so
they differ). One address (0x7ff...) is on the stack; the other,
0x402010, is a fixed address inside the program. Our text
abcdefgh is not passed to memcmp directly, so something changed it
first. (memcmp takes three arguments; ltrace guessed a fourth, 16,
which is leftover junk.)
Guess: the program transforms our 8 characters and compares the result
with 8 stored bytes at 0x402010.
Step 2: find main
$ readelf -h toylock-stripped (excerpt)
Type: EXEC (Executable file)
Entry point address: 0x4010f0
$ objdump -d -M intel toylock-stripped (excerpt, from 0x4010f0)
4010f0: f3 0f 1e fa endbr64
4010f4: 31 ed xor ebp,ebp
4010f6: 49 89 d1 mov r9,rdx
4010f9: 5e pop rsi
4010fa: 48 89 e2 mov rdx,rsp
4010fd: 48 83 e4 f0 and rsp,0xfffffffffffffff0
401101: 50 push rax
401102: 54 push rsp
401103: 45 31 c0 xor r8d,r8d
401106: 31 c9 xor ecx,ecx
401108: 48 c7 c7 ac 12 40 00 mov rdi,0x4012ac
40110f: ff 15 c3 2e 00 00 call QWORD PTR [rip+0x2ec3] # 403fd8 <fgets@plt+0x2ef8>
401115: f4 hlt
mov rdi,0x4012ac, then call, then hlt: main is at 0x4012ac.
The label <fgets@plt+0x2ef8> is one of objdump's invented names. The call
really goes through the slot at 0x403fd8, and readelf -r confirms that
slot belongs to __libc_start_main:
$ readelf -r toylock-stripped | grep 403fd8
000000403fd8 000100000006 R_X86_64_GLOB_DAT 0000000000000000 __libc_start_main@GLIBC_2.34 + 0
Step 3: static analysis with Ghidra
This is Ghidra's decompiler output (captured with the headless script from
"Ghidra and gdb in this dojo"; the GUI's Decompile panel shows the same). Ghidra
calls main FUN_004012ac, after its address:
undefined8 FUN_004012ac(void)
{
int iVar1;
char *pcVar2;
undefined8 uVar3;
size_t sVar4;
char local_48 [64];
printf("Password: ");
pcVar2 = fgets(local_48,0x40,stdin);
if (pcVar2 == (char *)0x0) {
uVar3 = 1;
}
else {
sVar4 = strcspn(local_48,"\n");
local_48[sVar4] = '\0';
iVar1 = FUN_0040124b(local_48);
if (iVar1 == 0) {
puts("Wrong.");
}
else {
puts("Door open.");
}
uVar3 = 0;
}
return uVar3;
}
Read it top to bottom: read up to 64 bytes into local_48, cut off the
newline, call FUN_0040124b with our text. If it returns 0, "Wrong.";
otherwise "Door open.". So FUN_0040124b is the checker. In the GUI you
would now press L on it and rename it check.
bool FUN_0040124b(char *param_1)
{
int iVar1;
size_t sVar2;
bool bVar3;
undefined1 local_10 [8];
sVar2 = strlen(param_1);
if (sVar2 == 8) {
FUN_004011d6(param_1,local_10,8);
iVar1 = memcmp(local_10,&DAT_00402010,8);
bVar3 = iVar1 == 0;
}
else {
bVar3 = false;
}
return bVar3;
}
This matches the ltrace guess. The password must be exactly 8 characters.
FUN_004011d6 is given our text, an 8-byte buffer local_10, and the number
8; then the buffer is compared with the 8 bytes at 0x402010
(DAT_00402010 is Ghidra's name for "some data at this address"). So
FUN_004011d6 fills the buffer from our input: it is the encode step.
void FUN_004011d6(long param_1,long param_2,int param_3)
{
int local_c;
for (local_c = 0; local_c < param_3; local_c = local_c + 1) {
*(char *)(param_2 + local_c) =
(char)local_c + *(char *)(param_1 + local_c) ^ (&DAT_00402008)[local_c % 4];
}
return;
}
Rename in your head: param_1 is in, param_2 is out, param_3 is n,
local_c is i, DAT_00402008 is KEY. Then the line reads:
out[i] = (i + in[i]) ^ KEY[i % 4];
C does + before ^, so the addition happens first. KEY[i % 4] is a
4-entry lookup table at 0x402008: position 0 uses KEY[0], position 4
uses KEY[0] again.
Check the decompiler against the assembly when it matters. Here is the
same loop from objdump (excerpt; objdump's invented <fgets@plt+...> labels
left as they are):
4011e9: c7 45 fc 00 00 00 00 mov DWORD PTR [rbp-0x4],0x0
4011f0: eb 4d jmp 40123f <fgets@plt+0x15f>
4011f2: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
4011f5: 48 63 d0 movsxd rdx,eax
4011f8: 48 8b 45 e8 mov rax,QWORD PTR [rbp-0x18]
4011fc: 48 01 d0 add rax,rdx
4011ff: 0f b6 00 movzx eax,BYTE PTR [rax]
401202: 89 c2 mov edx,eax
401204: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
401207: 01 d0 add eax,edx
401209: 89 c1 mov ecx,eax
40120b: 8b 55 fc mov edx,DWORD PTR [rbp-0x4]
40120e: 89 d0 mov eax,edx
401210: c1 f8 1f sar eax,0x1f
401213: c1 e8 1e shr eax,0x1e
401216: 01 c2 add edx,eax
401218: 83 e2 03 and edx,0x3
40121b: 29 c2 sub edx,eax
40121d: 89 d0 mov eax,edx
40121f: 48 98 cdqe
401221: 0f b6 80 08 20 40 00 movzx eax,BYTE PTR [rax+0x402008]
401228: 31 c1 xor ecx,eax
40122a: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
40122d: 48 63 d0 movsxd rdx,eax
401230: 48 8b 45 e0 mov rax,QWORD PTR [rbp-0x20]
401234: 48 01 d0 add rax,rdx
401237: 89 ca mov edx,ecx
401239: 88 10 mov BYTE PTR [rax],dl
40123b: 83 45 fc 01 add DWORD PTR [rbp-0x4],0x1
40123f: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
401242: 3b 45 dc cmp eax,DWORD PTR [rbp-0x24]
401245: 7c ab jl 4011f2 <fgets@plt+0x112>
Line by line, in groups:
4011e9–4011f0: i = 0 (i lives at [rbp-0x4]), then jump to the
condition at 40123f.
4011f2–4011ff: rax = in + i, then read one byte from there:
eax = in[i]. (in was saved at [rbp-0x18] when the function started.)
401202–401209: ecx = i + in[i].
40120b–40121f: eax = i % 4 (the and edx,0x3 pattern).
401221: eax = KEY[i % 4], one byte from the table at 0x402008.
401228: ecx = ecx ^ eax: the XOR.
40122a–401239: write the low byte of ecx (dl) to out[i].
40123b–401245: i = i + 1; go back to 4011f2 while i < n (jl,
so exactly n rounds). n was saved at [rbp-0x24].
Same rule as Ghidra's. Now the data. KEY is at 0x402008 (4 bytes) and the
stored answer at 0x402010 (8 bytes, from memcmp):
$ objdump -s -j .rodata toylock-stripped
toylock-stripped: file format elf64-x86-64
Contents of section .rodata:
402000 01000200 00000000 1337427f 00000000 .........7B.....
402010 63553219 76472905 50617373 776f7264 cU2.vG).Password
402020 3a20000a 00446f6f 72206f70 656e2e00 : ...Door open..
402030 57726f6e 672e00 Wrong..
The row starting 402000 holds addresses 0x402000 to 0x40200f.
0x402008 is 8 bytes into it: skip 01000200 00000000, and the next four
bytes are the key, 13 37 42 7f. The stored bytes start the next row:
63 55 32 19 76 47 29 05.
Step 4: dynamic analysis with gdb
Static analysis gave us a rule. Let us watch it happen, to make sure we read
it right. We stop at the xor (0x401228) and print both operands. Input is
abcdefgh from a file. (Captured with gdb -nx -batch, which turns pwndbg
off so the output is short; each command is shown before its output, and two
[Thread debugging ...] lines gdb prints after run are cut. Your screen
will also show pwndbg's panels.)
$ echo abcdefgh > /tmp/guess.txt
$ gdb -q ./toylock-stripped
(gdb) break *0x401228
Breakpoint 1 at 0x401228
(gdb) run < /tmp/guess.txt
Breakpoint 1, 0x0000000000401228 in ?? ()
(gdb) x/1i $pc
=> 0x401228: xor ecx,eax
(gdb) p/x $eax
$1 = 0x13
(gdb) p/x $ecx
$2 = 0x61
(gdb) continue
Breakpoint 1, 0x0000000000401228 in ?? ()
(gdb) p/x $eax
$3 = 0x37
(gdb) continue
Breakpoint 1, 0x0000000000401228 in ?? ()
(gdb) p/x $eax
$4 = 0x42
Round 0: ecx is 0x61 ('a' plus 0) and eax is KEY[0] = 0x13. Rounds 1
and 2 bring 0x37 and 0x42: the program is walking the table, as
predicted. (?? () means gdb has no function name for this address,
because the program is stripped.)
Next, stop at the memcmp call (0x40129d) and look at both buffers. rdi
and rsi hold its first two arguments:
(gdb) delete
(gdb) break *0x40129d
Breakpoint 2 at 0x40129d
(gdb) continue
Breakpoint 2, 0x000000000040129d in ?? ()
(gdb) x/1i $pc
=> 0x40129d: call 0x4010d0 <memcmp@plt>
(gdb) x/8bx $rdi
0x7fffffffeb38: 0x72 0x54 0x27 0x18 0x7a 0x5c 0x2f 0x10
(gdb) x/8bx $rsi
0x402010: 0x63 0x55 0x32 0x19 0x76 0x47 0x29 0x05
Check the first byte by hand: ('a' + 0) ^ 0x13 = 0x61 ^ 0x13 = 0x72. It
matches the first byte of our scrambled input. The rule is confirmed.
Changing a register. Right after memcmp returns, check runs
test eax,eax at 0x4012a2: eax is 0 only if the buffers matched. In a
fresh gdb session, stop there, overwrite eax, and continue:
(gdb) break *0x4012a2
Breakpoint 1 at 0x4012a2
(gdb) run < /tmp/guess.txt
Breakpoint 1, 0x00000000004012a2 in ?? ()
(gdb) x/1i $pc
=> 0x4012a2: test eax,eax
(gdb) p $eax
$1 = 15
(gdb) set $eax = 0
(gdb) p $eax
$2 = 0
(gdb) continue
Password: Door open.
[Inferior 1 (process 30) exited normally]
With the wrong password, the door opened. This proves we found the decision
point. It did not give us the password, and in most challenges it would
not give you the flag either, because a program started by gdb loses its
permission to read /flag.
Step 5: turn the rule into a script
The program did out[i] = (in[i] + i) ^ KEY[i % 4] and compared out with
the stored bytes. Undo it in reverse order: XOR first, then subtract i.
# Undo toylock's scramble: out[i] = (in[i] + i) ^ KEY[i % 4]
key = bytes.fromhex("1337427f") # the 4-byte table at 0x402008
secret = bytes.fromhex("6355321976472905") # the 8 bytes at 0x402010
password = []
for i, b in enumerate(secret):
x = b ^ key[i % 4] # undo the XOR first (it was done last)
x = (x - i) & 0xff # then undo "+ i", keeping one byte
password.append(x)
print(bytes(password).decode())
$ python3 undo_toylock.py
pancakes
Step 6: confirm on the real program
$ python3 undo_toylock.py | ./toylock-stripped
Password: Door open.
Done: the program itself accepted the answer.
The source, revealed
#include <stdio.h>
#include <string.h>
static const unsigned char KEY[4] = { 0x13, 0x37, 0x42, 0x7f };
static const unsigned char SECRET[8] = {
0x63, 0x55, 0x32, 0x19, 0x76, 0x47, 0x29, 0x05
};
static void scramble(const char *in, unsigned char *out, int n)
{
for (int i = 0; i < n; i++)
out[i] = (in[i] + i) ^ KEY[i % 4];
}
int check(const char *guess)
{
unsigned char buf[8];
if (strlen(guess) != 8)
return 0;
scramble(guess, buf, 8);
return memcmp(buf, SECRET, 8) == 0;
}
int main(void)
{
char line[64];
printf("Password: ");
if (!fgets(line, sizeof line, stdin))
return 1;
line[strcspn(line, "\n")] = '\0';
if (check(line))
puts("Door open.");
else
puts("Wrong.");
return 0;
}
Built with the same options as this module's challenges, then stripped:
gcc -O0 -fno-stack-protector -no-pie -fno-pie -o toylock toylock.c
cp toylock toylock-stripped
strip toylock-stripped
To follow along, save the source as ~/toylock.c and run those three
commands in your home directory. If your addresses differ from the ones
above, trust yours.