saarCTF 2022 – saarsecvv
saarsecvv is a CLI binary that was part of saarCTF 2022. Although this challenge earned us a lot of points, we did not come up with the exploit shown here by ourselves. This writeup will therefore focus on how to replicate an existing exploit and various ways to patch it.
Service Overview
The binary provided is a classic CLI application that provides an interactive ticket system. Users can register, login, and create tickets. After creating a ticket, they get handed a secret key that they can use to view the ticket ID and its content on subsequent runs.
Credentials, ticket IDs, and secret keys are stored in a folder based structure. The structure is as follows:
data/
|-- some_user/
| |-- GIi7ADUXYi6urmIHsP9/
| | `-- 2574236285
|-- another_user/
| `-- jdergud8HJ0nQk1bEG1/
| `-- 2571633044
| `-- 2571634075
| `-- 2571635090
Network Analysis
The binary is invoked via socat, which is a simple wrapper that allows to connect to the binary via a TCP socket on port 5445. By monitoring the traffic, we realized that the game server is planting flags by registering random users and sending the flag inside the metadata of newly created tickets. Our goal is to access those tickets without knowing the secret key (the target usernames are given by the game server).
On further network analysis, we noticed ongoing attacks on our service. Instead of providing a secret key, the attackers provided a dot-dot-slash prepended username. This was interpreted as a request to access the directory of arbitrary users, printing the contents (secret keys) of the directory.
Binary Analysis
Equipped with the knowledge of this specific attack, we were able to quickly identity the faulting function.
unsigned int __cdecl printTickets(const char *secretKey)
{
char i; // [esp+13h] [ebp-115h]
DIR *d; // [esp+14h] [ebp-114h]
struct dirent *dir; // [esp+18h] [ebp-110h]
FILE *fptr; // [esp+1Ch] [ebp-10Ch]
char tmp[100]; // [esp+22h] [ebp-106h] BYREF
char tmp2[150]; // [esp+86h] [ebp-A2h] BYREF
unsigned int canary; // [esp+11Ch] [ebp-Ch]
canary = __readgsdword(0x14u);
strncpy(tmp, DATA_DIR, 6u);
strcat(tmp, CURR_USER);
*(_WORD *)&tmp[strlen(tmp)] = 47;
strcat(tmp, secretKey);
d = opendir(tmp);
if ( d )
{
puts("Here is your ticketcode and your ticket:");
while ( 1 )
{
dir = readdir(d);
if ( !dir )
break;
if ( strcmp(dir->d_name, ".") && strcmp(dir->d_name, "..") )
{
puts(dir->d_name);
strncpy(tmp2, tmp, 100u);
*(_WORD *)&tmp2[strlen(tmp2)] = '/';
strcat(tmp2, dir->d_name);
fptr = fopen(tmp2, "r");
if ( fptr )
{
for ( i = fgetc(fptr); i != -1; i = fgetc(fptr) )
putchar(i);
puts("\n");
fclose(fptr);
}
}
}
}
closedir(d);
}
return canary - __readgsdword(0x14u);
}
Sanitizing input passed to opendir() should prevent attackers from accessing arbitrary files.
Patching The Service - LD_PRELOAD
Since the binary is dynamically linked, we can use LD_PRELOAD to load a custom library that provides the necessary hook:
#define _GNU_SOURCE
#include <dirent.h>
#include <dlfcn.h>
#include <string.h>
typedef DIR *(*opendir_t)(const char *name);
opendir_t real_opendir;
DIR *opendir(const char *name) {
if (!real_opendir) {
real_opendir = dlsym(RTLD_NEXT, "opendir");
}
if (strstr(name, "../")) {
// Prevent path traversal
return NULL;
}
return real_opendir(name);
}
Equipped with this shared object, we only need to instruct the dynamic linker to use our modified export:
socat tcp-l:5445,fork,reuseaddr, \
EXEC:"env LD_PRELOAD=$PWD/saarsecvv_hook.so ./saarsecVV", \
setsid,pty,stderr,sigint,rawer,echo=0
Patching The Service - Frida
Ever wanted to write some Python and JavaScript code in the same file? Well, now you can! Frida is a dynamic binary instrumentation framework that allows us to execute JavaScript code in the context of a running process.
Since our target binary includes debugging symbols, we can leverage them to locate the function we want to hook.
We can then use the Memory
API to replace the functions secretKey
argument with a sanitized version.
import frida
import sys
session = frida.attach("saarsecVV")
# Hook the printTickets function
script = session.create_script("""
Interceptor.attach(DebugSymbol.getFunctionByName("printTickets"), {
onEnter: function (args) {
// Sanitize the secretKey argument
var secretKey = args[0].readCString();
var strippedKey = Memory.allocUtf8String(
secretKey.replace('../', ''));
this.strippedKey = strippedKey;
args[0] = strippedKey;
send('Called!: '
+ secretKey + ' -> '
+ strippedKey.readCString());
}
});
""")
def on_message(message, data):
if message['type'] == 'send':
print("[*] " + message['payload'])
elif message['type'] == 'error':
print("[!] " + message['stack'])
script.on('message', on_message)
script.load()
sys.stdin.read()
Dynamic invocation
Since our target binary gets dynamically invoked by socat, we opt for a preloaded mode of operation. By LD_PRELOADing the frida gadget, we can execute JavaScript of our choice on each invocation. First, we need to create a configuration file for the gadget:
{
"interaction": {
"type": "script",
"path": "/home/user/saarsecvv.js"
}
}
The /home/user/saarsecvv.js
file contains the following code:
Interceptor.attach(DebugSymbol.getFunctionByName("printTickets"), {
onEnter: function(args) {
var secretKey = args[0].readCString();
var strippedKey = Memory.allocUtf8String(secretKey
.replace('../', ''));
this.strippedKey = strippedKey;
args[0] = strippedKey;
send('Called!: '
+ secretKey + ' -> '
+ strippedKey.readCString());
}
});
All that’s left to do is to tell socat to preload the gadget:
socat tcp-l:5445,fork,reuseaddr, \
EXEC:"env LD_PRELOAD=$PWD/saarsecvv_hook.so ./saarsecVV", \
setsid,pty,stderr,sigint,rawer,echo=0