Flare-on 11 Challenge 5 Write-up — SSHD:

Raviv Rachmiel
7 min readNov 9, 2024

--

OR why I prefer debugging over hiking

Alright, confession time. I am here sitting in this amazing hotel right in the middle of New Zealand’s best views of the south island and reverse engineering a crash dump. Why? well, first of all, because it’s fun but mostly, because I’m not going to fail this year’s Flare-on challenge no matter what. I have a record to keep.

Lake Wanaka, NZ

While everyone is out there enjoying the great outdoors, hiking up some majestic mountain trail with breathtaking views, I’m here, sitting comfortably in front of my screen, trying to reverse-engineer an sshd crash dump, which is actually the core of challenge 5 in the Flare-on11 annual challenge. Who needs fresh air and sore legs when you can breathe in assembly code and chase down some communication protocols, right?

Don’t get me wrong, hiking is fine… for people who like that sort of thing. But there’s something about unraveling the intricacies of a piece of software, digging deep into its binary soul, that gets my heart racing way more than a steep incline ever could. So, grab a drink (preferably caffeinated), and let me take you on a journey of how I cracked challenge number 5 of the 11th annual Flare-on Challenge called “sshd”.

Step 1: Intro (or, how I avoided nature altogether)

The challenge started with the mission statement — Here is a tar file which is a copy of a disk after a crash, help us understand what happened.

When unzipping the tar file and opening its content in “everything.exe”, I could see an interesting take regarding the last edited files:

So first of all, of course the “flag.txt” file is just a decoy and has nothing in it:

But it did make me assume that looking at the last edited files is the right thing to do.
Another thing that popped was the sshd.core crash dump and of course the modification of the liblzma, especially with the famous backdoor that was found in it earlier this year — the XZ backdoor (https://securelist.com/xz-backdoor-story-part-1/112354/)

At this point I knew there was only one right thing to do — open IDA.

Step 2: Let’s Get Our Hands Dirty

After loading the so executable and crashdump into the IDA, I knew I had two possible approaches:

  1. Understand from the dump file what happened (this option would maybe require gdb to get the stack context).
  2. Try to find a backdoor in the liblzma so file, possibly where the XZ backdoor was in the CVE found earlier this year.

I started from the gdb context, found a blob that looked suspicious:

and decided to search for the first bytes of it in the liblzma.
Coincidentally, or not, I found it right where the backdoor of XZ is — it was a check of a magic:

Quick RE showed a chacha/salsa encryption function, with a 0x20 byte key and a 12 byte nonce gotten as parameters (can be found in the stack followed by the magic) to decrypt the &unk_23960 of size 0xf96.

This has to be a shellcode because it is being called to in line 20 — v13() (later named also “res”);

A quick play with cyberfchef yielded a functioning shellcode:

Not the fun part really starts.

Step 3: Reversing the shellcode

Here’s where it got exciting. The code wasn’t too difficult to statically analyze. It has basic unix syscalls and is doing a simple socket communication to get a key, a nonce, the length of a filename and a filename. The shellcode opens the file with the filename, reads its content and then encrypts/decrypts it (it’s the same) with chacha20 of the key and the nonce and sends it to the hardcoded ip on port 1337.

open a socket and get a key of len 32
get the nonce as well

So the stack of the shellcode pretty much looks as the following:

Stack frame at the start of the SC function

At this point, I went back to the crash dump, about 1278h before the context I found before, to look for a blob that looks like a key, a nonce and after them a filename_path and about 100h after it the content.

I found this:

A file name with a relevant blob, I named key

From here, everything looked promising because all the offsets aligned. I thought I had it but I couldn’t be more wrong.

Step 4: Crunching numbers until deciding to blackbox

  • At this point I added everything to CyberChef and checked the decryption just to see that it failed.
  • I felt like I had everything right in terms of the content but maybe I missed something in the encryption algorithm?

At times like this you only have one option left — dynamic instrumentation + Blackboxing the sh*t out of this challenge.
And so I did; I built a python server (which took a minute and a half with claude AI) to send the content as needed to the shellcode and at the same time dynamically debugged the shellcode on an ubuntu VM while patching everything so it will talk with my claude socket server:

content in the remote debugging phase
import socket
import base64
import time

def decode_base64(encoded_string):
return base64.b64decode(encoded_string)

# List of base64 encoded strings
encoded_messages = [
"jeyREut2Dtp8fYekQyccNdngy4eJk7TZBK75NPohZtc=", #key
"ERERERERERERERER", # "This is NONCE"
"AAAADA==", # "filename.txt size"
"ZmlsZW5hbWUudHh0" # "filename.txt"
]

# Server configuration
HOST = '0.0.0.0' # localhost
PORT = 1337 # Port to listen on (non-privileged ports are > 1023)

def run_server():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((HOST, PORT))
s.listen()
print(f"Server is listening on {HOST}:{PORT}")

conn, addr = s.accept()
with conn:
print(f"Connected by {addr}")
for encoded_message in encoded_messages:
decoded_message = decode_base64(encoded_message)
conn.sendall(decoded_message)
print(f"Sent: {decoded_message}")
time.sleep(1) # Wait for 1 second between messages

print("All messages sent. Closing connection.")

if __name__ == "__main__":
run_server()

Step 5: Profit

While debugging and connecting to the server described above, I stopped just before the send function; I looked at the content to be sent and found exactly what I was looking for:

Profit.

Bonus Step: Trying it out with Frida

A dear friend challenged me to try and solve it with Frida and I can never say no to a good challenge, especially if this challenge is also a good excuse to skip another hike.

Therefore, I tried the following scripts (with the help of claude):

import frida
import sys

def on_message(message, data):
print("[%s] => %s" % (message, data))

def main(target_process):
session = frida.attach(target_process)

script = session.create_script("""
var socketFd;
var recvCount = 0;
var messages = ["hello world", "hi there", "third", "fourth", "fifth"];

Interceptor.attach(Module.getExportByName(null, 'socket'), {
onLeave: function(retval) {
socketFd = retval.toInt32();
console.log('[*] New socket created with fd: ' + socketFd);
}
});

Interceptor.attach(Module.getExportByName(null, 'connect'), {
onEnter: function(args) {
var fd = args[0].toInt32();
if (fd == socketFd) {
var sockaddr_ptr = args[1];
var sockaddr = Memory.readByteArray(sockaddr_ptr, 16);
var ip = [sockaddr[4], sockaddr[5], sockaddr[6], sockaddr[7]].join('.');
var port = (sockaddr[2] << 8) | sockaddr[3];
console.log('[*] connect() called with fd ' + fd);
console.log('[*] Destination: ' + ip + ':' + port);
console.log('[*] Forcing connect() to succeed');
}
},
onLeave: function(retval) {
retval.replace(0); // Force connect() to always succeed (return 0)
}
});

Interceptor.attach(Module.getExportByName(null, 'send'), {
onEnter: function(args) {
var fd = args[0].toInt32();
if (fd == socketFd) {
var buf = args[1];
var len = args[2].toInt32();
var data = Memory.readByteArray(buf, len);
console.log('[*] send() called with fd ' + fd);
console.log('[*] Data sent: ' + hexdump(data));
}
}
});

Interceptor.attach(Module.getExportByName(null, 'recv'), {
onEnter: function(args) {
var fd = args[0].toInt32();
if (fd == socketFd) {
this.buf = args[1];
this.len = args[2].toInt32();
console.log('[*] recv() called with fd ' + socketFd);
var message = recvCount < messages.length ? messages[recvCount] : "No more predefined messages";
console.log('[*] Forcing recv() to return "' + message + '"');
}
},
onLeave: function(retval) {
if (this.buf) {
var message = recvCount < messages.length ? messages[recvCount] : "No more predefined messages";
Memory.writeUtf8String(this.buf, message);
console.log('[*] Data received: ' + hexdump(Memory.readByteArray(this.buf, message.length)));
retval.replace(message.length);
this.buf = null;
recvCount++;
}
}
});
""")

script.on('message', on_message)
script.load()
print("[!] Ctrl+D on UNIX, Ctrl+Z on Windows/cmd.exe to detach from instrumented program.\n\n")
sys.stdin.read()
session.detach()

if __name__ == '__main__':
if len(sys.argv) != 2:
print("Usage: %s <process name or PID>" % __file__)
sys.exit(1)

try:
target_process = int(sys.argv[1])
except ValueError:
target_process = sys.argv[1]

main(target_process)

The bad news is that no matter how hard I tried, I couldn’t trigger the connection functions (anyone interested in trying — I would love to have a good discussion about it down bellow in the comments).

The good news is that it took the whole day and I didn’t need to go hiking that day

Jokes aside, I love hitting a good hike as much as I love hitting the debugger. But (Shout out to flare-on CTF developers) Nature can wait, when it’s flare-on we’re talking about.

--

--

Raviv Rachmiel
Raviv Rachmiel

Written by Raviv Rachmiel

Cyber-Security Researcher | Entrepreneur | World Traveler. Doing my best to combine all of the above; Sometimes it works. https://ravivrach.com

No responses yet