Cyber Apocalypse 2025 - pwn_contractor
This post is part of a series where I describe some Cyber Apocalypse 2025 CTF challenges I participated in.
The list of posts is as follows:
Enjoy!
pwn_contractor
In this challenge, the user is prompted with multiple questions that store user input in specific buffers. One of the questions is about the specialty:
printf(
"\n"
"[%sSir Alaric%s]: You sound mature and experienced! One last
thing, you have a certain specialty in combat?\n\n> ",
"\x1B[1;33m",
"\x1B[1;34m");
for ( i = 0; (unsigned int)i <= 0xF; ++i )
{
read(0, &safe_buffer, 1uLL);
if ( safe_buffer == 10 )
break;
*((_BYTE *)s + i + 0x118) = safe_buffer;
}
It limits the input to 16 characters. Afterward, it prints the three pieces of information requested (name, reason to join, age, and specialty).
...
printf(
"\n"
"[%sSir Alaric%s]: So, to sum things up: \n"
"\n"
"+---------------- ...
"\n"
"\t[Name]: %s\n"
"\t[Reason to join]: %s\n"
"\t[Age]: %ld\n"
"\t[Specialty]: %s\n"
"\n"
"+----------------- ...-+\n"
...
The program asks the user if they would like to modify any of the responses. This is where the bug lies. In one of the prompts for response modification, it does not enforce the correct input length:
printf("\n%s[%sSir Alaric%s]: And what are you good at: ",
"\x1B[1;34m", "\x1B[1;33m", "\x1B[1;34m");
for ( i = 0; (unsigned int)i <= 0xFF; ++i ) {
read(0, &safe_buffer, 1uLL);
if ( safe_buffer == 10 )
break;
*((_BYTE *)buf_ptr + i + 0x118) = safe_buffer;
}
++v6;
This allows up to 256 bytes of input instead of just 16! Consequently, we can write a “specialty” that overflows the stack variable. At buf_ptr
+ 0x118, it will exceed the allocated buffer size of 0x130 after 0x18 bytes (24).
At this point, understanding the stack structure is crucial. By analyzing the function’s assembly, we can deduce the layout.
First, it reserves 0x20 bytes (4 qwords), with sub rsp, 0x20
:
00555555555445 push rbp
00555555555446 mov rbp, rsp
00555555555449 sub rsp, 20h
0055555555544D ; 10: v9 = __readfsqword(0x28u);
0055555555544D mov rax, fs:28h
00555555555456 mov [rbp-8], rax
The fs:0x28 is the stack canary and it is saved in RBP-8.
Then, comes the alloca
function (manpage). It starts by calculating the number of bytes to reserve in the stack
to hold 0x130 bytes, while complying with specific alignment rules.
The first part of the instructions is below. It calculates the number of pages (0x1000) from the buffer size. Since the buffer size is 0x130, there are no changes here, and it will jump to loc_5555555554A6 without making any change to the stack.
0055555555545A xor eax, eax
0055555555545C mov eax, 10h
00555555555461 sub rax, 1 ; rax=0xf
00555555555465 add rax, 130h ; rax=0x13f
0055555555546B mov ecx, 10h
00555555555470 mov edx, 0
00555555555475 div rcx ; rax=0x13, rdx=0xf
00555555555478 imul rax, 10h ; rax=0x130
0055555555547C mov rdx, rax ; rdx=0x130
0055555555547F and rdx, 0FFFFFFFFFFFFF000h
00555555555486 mov rcx, rsp ; rcx=rsp
00555555555489 sub rcx, rdx ; rcx=rcx-0
0055555555548C mov rdx, rcx ; rdx=rsp
0055555555548F cmp rsp, rdx ; rsp == rsp?
00555555555492 jz short loc_5555555554A6 <--
Then in the new location (loc_5555555554A6), it will subtract from the stack pointer 0x130 and peforms a memory operation in the last qword (at 0x12c), to ensure the stack page will be available (?).
005555555554A6 mov rdx, rax ; rdx=0x130
005555555554A9 and edx, 0FFFh ; rdx=0x130
005555555554AF sub rsp, rdx ; rsp=rsp-0x130
005555555554B2 mov rdx, rax ; rdx=0x130
005555555554B5 and edx, 0FFFh ; rdx=0x130
005555555554BB test rdx, rdx
005555555554BE jz short loc_5555555554D0
005555555554C0 and eax, 0FFFh ; eax=0x130
005555555554C5 sub rax, 8 ; rax=0x12c
005555555554C9 add rax, rsp ; rax=rsp+rax
005555555554CC or qword ptr [rax], 0
Finally, it saves the buffer pointer to RBP - 0x18, after rounding it up to the nearest 16-byte aligned address.
005555555554D0 loc_5555555554D0:
005555555554D0 mov rax, rsp
005555555554D3 add rax, 0Fh
005555555554D7 shr rax, 4
005555555554DB shl rax, 4
005555555554DF mov [rbp-0x18], rax
Thus, it will always subtract RSP by 0x130, but the pointer may be shifted to be 16-bytes aligned. The buffer structure is the following:
- 0x000: name
- 0x010: reason
- 0x110: age
- 0x118: specialty
Since RSP is subtracted by 0x130, the stack structure is:
Where N stands for Name, R for Reason, A for Age, and S for Specialty.
Binary Address Leak
After allocating memory, the program calls memset
to clear the first 0x128 bytes. However, this does not cover the entire allocated buffer—8 bytes remain uncleared. These leftover bytes contain the binary address of the __libc_csu_init
function. Since the program writes directly to the buffer without explicitly setting a null terminator, we can craft a payload that fills 16 bytes (covering all the green “S” boxes in the diagram above). When printed, the output will also include the adjacent bytes, leaking a binary address.
Contract Gadget
During the analysis of the binary, I discovered the contract
function, which is a readily available execve("/bin/sh")
code. With the binary address leak, we can bypass ASLR and obtain the memory address of contract
.
Stack Overflow
Initially, it seems straightforward to use the “specialty” input to overflow the stack variables up to the return address and redirect execution to the contract
function. However, there are two obstacles:
- The stack canary must be bypassed.
- The
buf_ptr
address must remain valid to avoid memory corruption.
If we look at the loops that take user input and save it in the buffer, we see that they take the buf_ptr
value continuously:
005555555555AA mov edx, 1 ; nbytes
005555555555AF lea rsi, safe_buffer ; buf
005555555555B6 mov edi, 0 ; fd
005555555555BB call _read
005555555555C0 movzx eax, cs:safe_buffer
005555555555C7 cmp al, 0Ah
005555555555C9 jz short loc_555555555600
005555555555CB mov eax, cs:i
005555555555D1 movzx ecx, cs:safe_buffer
005555555555D8 mov rdx, [rbp-18h] <--- here
005555555555DC cdqe
005555555555DE mov [rdx+rax+10h], cl <--- (1)
005555555555E2 mov eax, cs:i
005555555555E8 add eax, 1
005555555555EB mov cs:i, eax
005555555555F1 mov eax, cs:i
005555555555F7 cmp eax, 0FFh
005555555555FC jbe short loc_5555555555AA
If we overwrite one byte of it at (1), the next byte write will go to an unexpected memory address (i.e., in developer lingo, very nasty memory corruption bug).
Final Step
So, what can we do?
Actually, we can use this memory corruption to help us with the attack.
Imagine that we patch the first byte of the buf_ptr
at rbp - 0x18,
and the next byte is written with a 0x20-byte offset:
This means we can bypass the stack canary protection, and the subsequent writes will actually overwrite the return address, which is very handy. :)
But in order to do this, we’d need to know what LSB buf_ptr
has so that we can
calculate offset
+ 0x20 and replace buf_ptr LSB with that value, right? While
this is true, it’s also true that if we try a random value, there’s a
probability for it to work. The possible values are between 0x10 and 0xf0 - 0x20 = 0xd0, thus, if I’m doing this correctly, the probability is 1 in 200.
Instead of trying random offsets, I used a fixed value of 0x70 in an infinite loop, which succeeded after a few seconds.
...
[*] Closed connection to localhost port 1337
[*] Received: And what are you good at:
[Sir Alaric]: We are ready to recruit you young lad!
HTB{4_l1ttl3_bf_41nt_b4d_9df7754c8ecbe04e5e5df77790c472f5}
[*] Closed connection to localhost port 1337
The full solution can be downloaded from pwn_contractor.py.
The next challenge in the list of my posts is pwn_crossbow.