Bits and Bytes Security

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:

Stack Structure

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:

  1. The stack canary must be bypassed.
  2. 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.