Logo

NSA Codebreaker 2024 Writeup

Archan6el Archan6el
January 26, 2025
55 min read
index

Background:

Foreign adversaries have long strived to gain an advantage against the might of the United States Armed Forces. While matching the USA on the battlefield is a costly and risky proposition, our adversaries are always looking for ways to balance the playing field. A serious and real threat is the infiltration and sabotage of military operations before the fight even breaks out.

Fortunately, the NSA is always recruiting bright young individuals to help protect our country! In fact, a bunch of your friends graduated last year and have been busy at work in their Developmental Programs.

You have returned to NSA on your final Cooperative Education tour and are visiting your friend Aaliyah who is currently employed full-time in the Intelligence Analysis Development Program. Intelligence Analysts are always scouring through collected Signals Intelligence (SIGINT) for threat indicators. Aaliyah recently attended a briefing that highlighted Nation-State Advanced Persistent Threats (APT) targeting our Defense Industrial Base (DIB) contractors.

Task 0 - (Community of Practice, Discord Server)

Prompt 0:

As a participant in the Codebreaker Challenge, you are invited to join the New Mexico Tech Codebreaker Challenge Community of Practice! This is the 3rd year that NMT has partnered with the NSA Codebreaker Challenge. Its purpose remains to give students interested in cybersecurity a place to talk about Codebreaker, cybersecurity, and other related topics.

To complete this task, first, join the Discord server. https://discord.gg/SWYCM5xr4N

Once there, type /task0 in the #bot-commands channel. Follow the prompts and paste the answer the bot gives you below.

Solve

Just join the CBC Discord and follow the steps ¯\(ツ)

Response:

Welcome to the community! Remember, challenge tasks must be done solo and NO SPOILERS! Use ‘Get Help’ for a direct line to NSA.

Task 1 - No Token Left Behind - (File Forensics)

Prompt 1:

Aaliyah is showing you how Intelligence Analysts work. She pulls up a piece of intelligence she thought was interesting. It shows that APTs are interested in acquiring hardware tokens used for accessing DIB networks. Those are generally controlled items, how could the APT get a hold of one of those?

DoD sometimes sends copies of procurement records for controlled items to the NSA for analysis. Aaliyah pulls up the records but realizes it’s in a file format she’s not familiar with. Can you help her look for anything suspicious?

If DIB companies are being actively targeted by an adversary the NSA needs to know about it so they can help mitigate the threat.

Help Aaliyah determine the outlying activity in the dataset given

Downloads:

DoD procurement records (shipping.db)

Prompt:

Provide the order id associated with the order most likely to be fraudulent.

Solve

Okay so now we’re actually getting into the actual challenges. We need to find the order ID associated with the fraudulent order for this one. After downloading shipping.db, despite the .db extension, running file on it reveals that it is a Zip file. Let’s unzip it and see what we get.

image

We get a ton of files, most of which are unimportant:

image

content.xml is what seems to actually contain the data, the issue is just visualizing it. Thankfully, just popping it into Microsoft Excel (sorry pure Linux users) does the trick.

image

The spreadsheet is gigantic, with 11,550 rows. No way are we going through each row one by one.

Briefly scrolling through the spreadsheet, there’s a lot of things that are repeated, specifically emails and addresses. I figured that anything malicious would probably only show up once, so using the UNIQUE function in Excel, I isolated all unique entries and put them in their own column. Starting from the bottom upwards, most of the entries are order IDs, which make sense since they should be unique. However, the first odd entry when going from the bottom up is an address, 058 Flowers Square Apt. 948, Port Ryanshire, NE 05823:

image

It is associated with “Guardian Armaments”, and is part of an entry that has an order ID of GUA0094608

image

And when looking at all other Guardian Armaments entires, they use a different address, 0050 Fred Plaza Suite..., with the below image being a small example

image

058 Flowers Square Apt. 948, Port Ryanshire, NE 05823 is the only different address used by Guardian Armaments, meaning that it is likely fraudulent, and we are right!

Submitting its Order ID, GUA0094608, gets us our first badge

Response:

Great Work! That order does look fishy…

Task 2 - Driving Me Crazy - (Forensics, DevOps)

Prompt 2:

Having contacted the NSA liaison at the FBI, you learn that a facility at this address is already on a FBI watchlist for suspected criminal activity. With this tip, the FBI acquires a warrant and raids the location.

Inside they find the empty boxes of programmable OTP tokens, but the location appears to be abandoned. We’re concerned about what this APT is up to! These hardware tokens are used to secure networks used by Defense Industrial Base companies that produce critical military hardware.

The FBI sends the NSA a cache of other equipment found at the site. It is quickly assigned to an NSA forensics team. Your friend Barry enrolled in the Intrusion Analyst Skill Development Program and is touring with that team, so you message him to get the scoop. Barry tells you that a bunch of hard drives came back with the equipment, but most appear to be securely wiped. He managed to find a drive containing what might be some backups that they forgot to destroy, though he doesn’t immediately recognize the data. Eager to help, you ask him to send you a zip containing a copy of the supposed backup files so that you can take a look at it.

If we could recover files from the drives, it might tell us what the APT is up to. Provide a list of unique SHA256 hashes of all files you were able to find from the backups.

Downloads:

disk backups (archive.tar.bz2)

Prompt:

Provide your list of SHA256 hashes

Solve

We need to provide a list of all unique SHA256 hashes that we can find from the compressed disk backup we are given.

First of all, let’s run tar -xvf on the archive and see what we get

image

A whole bunch of these logseq files.

After running the file command on one of them, we see that they are part of a ZFS snapshot.

image

Note for my fellow WSL2 users, zfs seemingly doesn’t work on WSL2. In order to solve this challenge, I used a Kali virtualbox VM

After doing some googling on ZFS, I learn that all of these logseq files are essentially parts of the data on the drive, we just need to find a way to put them all together and mount or access the final result.

After doing some research on how we can access the data in the broken up drive, I found that you have to create a ZFS Pool and use an empty file to act as a virtual disk image.

First of all, the empty file. I used the truncate command to make a temporary file for this challenge, and placed it in my tmp directory:

sudo truncate -s 10G /tmp/task2

Now that we have the empty file, we can create our pool using it. I named my pool task2pool:

sudo zpool create task2pool /tmp/task2

OK we have our pool created and ready to go. So now how do we go about putting these logseq files together?

After some more research, I found that we can add the files to our pool using the following command:

sudo zfs receive -F task2pool/ltfs < logseq_file

But there’s one issue. We have to add / recieve them in order, logseq files are incremental.

How are we supposed to know which goes first? Well, looking back at when we ran file on one of the logseq files, we find two interesting things.

image

Each file has a destination and source GUID. This is what allows us to discern their order. We just have to find the first logseq file, which I assumed to be the one that didn’t have a source GUID.

That file ends up being logseq291502518216656, which is also the only logseq file that doesn’t end in -i, which is a pretty telltale sign that it’s likely the first one.

image

Now starting from this first file, we add it to our pool. We then follow the destination GUID to the next logseq file, and add that to our pool, and continue until we’ve added all files.

Of course, I didn’t want to do this by hand, so I made a bash script to do it.

#!/bin/bash
# Function to extract GUID from a snapshot file
get_guid() {
local file=$1
# Extract the GUID from the snapshot file using file command and grep
local guid=$(file "$file" | grep -oP '(?<=destination GUID: )[^\s]+')
echo "$guid"
}
# Function to extract the source GUID from a snapshot file
get_source_guid() {
local file=$1
# Extract the source GUID from the snapshot file using file command and grep
local guid=$(file "$file" | grep -oP '(?<=source GUID: )[^\s]+')
echo "$guid"
}
# Function to add the snapshot file to the ZFS pool
add_to_pool() {
local file=$1
echo "Adding $file to pool"
sudo zfs receive -F task2pool/ltfs < "$file"
}
# Start with an initial file
current_file="logseq291502518216656"
while [ -n "$current_file" ]; do
echo "Processing file: $current_file"
# Print the file name
echo "File name: $current_file"
# Add the current snapshot file to the pool
add_to_pool "$current_file"
# Get the destination GUID of the current file
current_dest_guid=$(get_guid "$current_file")
# Find the next file based on the source GUID
next_file=$(for file in *-i; do
# Check if the file contains the source GUID of the current file
if [ "$(get_source_guid "$file")" == "$current_dest_guid" ]; then
echo "$file"
break
fi
done)
# Check if we found a next file
if [ -n "$next_file" ]; then
current_file="$next_file"
else
echo "No next file found. Ending script."
break
fi
done

After running this bash script, we should have all files added to our pool. Running zfs list, we should see our mountpoint so that we know where to go to look at the final product.

image

So /task2pool/ltfs is where our data is. If we cd there, we find a planning directory, and within that, a logseq and pages directory.

image

Running find . -type f -exec sha256sum {} + | awk '{print $1}' | sort | uniq gets us a list of all the hashes here.

image

Challenge complete right?

Wrong. It’s not going to be that easy.

Submitting the hashes of these files was not the answer. I actually got stuck for a little bit here thinking I had the solution, and didn’t know where I was going wrong. I have the hashes of the files that are in the disk backup, which is seemingly all we need. What more could you want?

It wasn’t until after I carefully re-read the prompt that I realized what exactly they were asking for.

All SHA256 hashes that we can extract.

We are adding the ZFS volumes incrementally, and then taking a look at the final result. What if along the way, midway through putting the ZFS volumes together, we have access to different files, or at the very least, files that have different hashes?

I modify our bash script from earlier to essentially mount the filesystem after we add a new logseq file so that we can take a look at each step of the process. In other words, taking a snapshot of the filesystem at each step as we add each logseq file.

#!/bin/bash
# Function to extract GUID from a snapshot file
get_guid() {
local file=$1
# Extract the GUID from the snapshot file using file command and grep
local guid=$(file "$file" | grep -oP '(?<=destination GUID: )[^\s]+')
echo "$guid"
}
# Function to extract the source GUID from a snapshot file
get_source_guid() {
local file=$1
# Extract the source GUID from the snapshot file using file command and grep
local guid=$(file "$file" | grep -oP '(?<=source GUID: )[^\s]+')
echo "$guid"
}
# Function to add the snapshot file to the ZFS pool
add_to_pool() {
local file=$1
local filesystem_name=$2
echo "Adding $file to pool task2pool as $filesystem_name"
# Receive the snapshot into the pool as a new filesystem
sudo zfs receive -F task2pool/$filesystem_name < "$file"
# Set a mount point for the new filesystem
local mount_dir="/mnt/task2pool/$filesystem_name"
sudo zfs set mountpoint=$mount_dir task2pool/$filesystem_name
# Mount the new filesystem
sudo zfs mount task2pool/$filesystem_name
}
# Start with an initial file
initial_file="logseq291502518216656"
# Outer loop to handle multiple files. Loop from 1 to 20 since there are 20 logseq files
for i in {1..20}; do
# Initialize current_file for this iteration of outer loop
current_file="$initial_file"
count=0 # Reset the counter for each outer loop iteration
while [ -n "$current_file" ]; do
if [ $count -eq $i ]; then
break
fi
echo "Processing file: $current_file"
# Print the file name
echo "File name: $current_file"
# Add the current snapshot file to the pool as a new filesystem
add_to_pool "$current_file" "snapshot_$i"
# Get the destination GUID of the current file
current_dest_guid=$(get_guid "$current_file")
# Find the next file based on the source GUID
next_file=$(for file in *-i; do
# Check if the file contains the source GUID of the current file
if [ "$(get_source_guid "$file")" == "$current_dest_guid" ]; then
echo "$file"
break
fi
done)
# Check if we found a next file
if [ -n "$next_file" ]; then
current_file="$next_file"
else
echo "No next file found. Ending script."
break
fi
((count++)) # Increment the counter
done
done

In the script, I mounted everything in /mnt/task2pool. If we go there and run ls, we see 20 snapshot directories, which makes sense since there are 20 logseq files:

image

And sure enough if we run ls -lR (this being a small snippet of the output)…

image

At nearly each step of the process, each snapshot has its own set of files, which may very well have their own hashes.

Running find . -type f -exec sha256sum {} + | awk '{print $1}' | sort | uniq will print them all to the console for us, resulting in a pretty long output of SHA256 hashes. Waaaay longer than what we had before:

00d0d281c60c8abc7a78dea8be550b838555651f1c84b9629eb972ab373178b1
0409752c2490cbc8dd02208990d1cf54a619095766ebb5c28aa9af9f2c2d7cff
0691ef7bdda133ccfe7121ebd7cc470e76cbfed92f3ba6d7beef395b2618b1b7
0b7f06a779afd2c408b3949bd37827d7280710fdbecb68b19c79ae98d004db88
0b833f977a126d35858a16ab6df8dd09855e9669f9f24ff2348e44d861a17d59
0b88abca1862864b9b7686d2a065ffd07ef9572a72ae31caefb0636ccbb31f97
0e24142e652d5bc6b3d958756d5e5025fb6e9aef48fa1e9dbddfddc3fe7cdde7
106dc2f1806f113a860d270ff0203a9f65fdc6d4a5f0b9d8bf661b0cb07f28fb
1607fe4cef2704de242f18b020b3967887ed0b0e427619ffdb96da35e9e28010
165ffb243fda5f25c78c87aeca8d6b867e94240f027856273f9e9927dda5571e
...
...
Over 100 hashes
...
...

And submitting this gets us our second badge!

Results:

That is correct!

Task 3 - How did they get in? - (Reverse Engineering, Vulnerability Research)

Prompt 3:

Great work finding those files! Barry shares the files you extracted with the blue team who share it back to Aaliyah and her team. As a first step, she ran strings across all the files found and noticed a reference to a known DIB, “Guardian Armaments” She begins connecting some dots and wonders if there is a connection between the software and the hardware tokens. But what is it used for and is there a viable threat to Guardian Armaments (GA)?

She knows the Malware Reverse Engineers are experts at taking software apart and figuring out what it’s doing. Aaliyah reaches out to them and keeps you in the loop. Looking at the email, you realize your friend Ceylan is touring on that team! She is on her first tour of the Computer Network Operations Development Program

Barry opens up a group chat with three of you. He wants to see the outcome of the work you two have already contributed to. Ceylan shares her screen with you as she begins to reverse the software. You and Barry grab some coffee and knuckle down to help.

Figure out how the APT would use this software to their benefit

Downloads:

Executable from ZFS filesystem (server)

Retrieved from the facility, could be important? (shredded.jpg)

Prompt:

Enter a valid JSON that contains the (3 interesting) keys and specific values that would have been logged if you had successfully leveraged the running software. Do ALL your work in lower case.

Solve

Here we go, Task 3. We’re finally getting to some actual rev.

First off, let’s download the server executable and the shredded.jpg image. Opening up the image first, we are met with this:

image

Seems to be something written on shredded paper, which was crudely put back together. It looks like it reads JASPER_0, or it could be JASPER_O. We’ll keep note of it for now, and move on to the server executable.

Trying to run it gives us some interesting information.

image

We learn two key things from this. First, the server executable seems to be using something called rpc, which we can deduce from the rpc error message. Second, the executable needs to be able to ping some kind of auth service in order to work.

After doing some research, we find that rpc is a protocol used to call remote functions. So the server executable is probably trying to call some kind of ping function from an auth server. Let’s pop server into Ghidra and Binja and see what we find. I like to use both, since in some cases, Ghidra makes it easier to see some things than Binja, and vice versa.

After Ghidra does its analysis, we find that server is a Go binary. Trying to find the main function, we find a whole lot of interesting functions, but among them, two Ping functions

image

However, there isn’t really anything interesting there to build off of within them, but we’ll keep our eye on these main functions.

After some more snooping around, I stumble upon a jackpot of interesting functions each beginning with auth. Since the server executable is trying to ping what it calls an auth server, we’re probably in the right place. Specifically here for these functions under authServiceClient, these seem to outline the RPC methods that the executable is able to call on the auth server, which are Authenticate, Logout, Ping (the one we’re looking for), RefreshToken, RegisterOTPSeed, and VerifyOTP. So 6 in total.

image

Also under these auth functions, we can find functions that correspond to the requests and responses for the 6 functions we found above. So for Ping, we have PingRequest and PingResponse functions.

image

We see some sub-functions that shed light on what parameters or fields each method is expecting for requests and responses. For example, for the AuthRequest function, there are sub-functions called GetPassword and GetUsername, which means that it probably expects a password and username as fields in the request.

image

However, the most important thing for us is the PingRequest function, and if we take a look, it has a GetPing sub-function, which means it probably expects that as a field in its request.

image

We can deduce the fields for all 6 methods here in the same way. So we can start trying to make the auth server now, but how?

Since the server executable is in Go, we’ll make the auth server in Go too. Go’s implementation of the rpc protocol is grpc, and this guide was helpful in getting started. Essentially, we first need to create a .proto file in which we define each of our methods, as well as their request and response fields. That should be relatively easy to do based on what we found in Ghidra. The issue however is the package and service name that each .proto file needs. This is a little problematic because specifically the service needs to match on both the client and the server.

Thankfully, using both Ghidra and Binja was pretty helpful here. If we go into the auth/auth_grpc.(*authServiceClient).Ping function we found in Ghidra on Binja, near the end we can see some interesting text that seems to refer to an error with a function call.

image

The text refers to auth_service/AuthService. auth_service is likely our package name, and AuthService is our service name.

Now we have all we need, let’s create our proto file. I name mine ping.proto since we’re trying to get the ping function to work specifically, and set my go_package to /seedGeneration, since we saw some references to seedGeneration in those main functions we found earlier. The name of your proto file and go_package doesn’t matter though.

syntax = "proto3";
package auth_service;
option go_package = "/seedGeneration";
service AuthService {
rpc Authenticate(AuthenticateRequest) returns (AuthenticateResponse);
rpc Logout(LogoutRequest) returns (LogoutResponse);
rpc Ping(PingRequest) returns (PingResponse);
rpc RefreshToken(RefreshTokenRequest) returns (RefreshTokenResponse);
rpc RegisterOTPSeed(RegisterOTPSeedRequest) returns (RegisterOTPSeedResponse);
rpc VerifyOTP(VerifyOTPRequest) returns (VerifyOTPResponse);
}
message AuthenticateRequest {
// Define fields needed for authentication
string username = 1; // User's username
string password = 2; // User's password
}
message AuthenticateResponse {
// Define fields for the response
bool success = 1; // Indicates if authentication was successful
string message = 2; // Optional message for additional information
}
message LogoutRequest {
// Define fields needed for logout
}
message LogoutResponse {
// Define fields for the response
}
message PingRequest {
// Define fields needed for the request
int64 ping = 1;
}
message PingResponse {
int64 pong = 1;
}
message RefreshTokenRequest {
// Define fields needed for refresh token
}
message RefreshTokenResponse {
// Define fields for the response
}
message RegisterOTPSeedRequest {
// Define fields needed for OTP seed registration
string username = 1;
int64 seed = 2;
}
message RegisterOTPSeedResponse {
// Define fields for the response
bool success = 1;
}
message VerifyOTPRequest {
// Define fields needed for OTP verification
string username = 1;
int64 otp = 2;
}
message VerifyOTPResponse {
// Define fields for the response
bool success = 1;
int64 token = 2;
}

With our .proto file made, we run the protoc command to compile it into some Go files for us to use

protoc --go_out=. --go-grpc_out=. ping.proto

Now let’s create the auth server. In my code, I set up some sample checks for AuthenticateRequest and VerifyOTP just to see if they do anything. Most importantly, we run the server on port 50052.

package main
import (
"context"
"fmt"
"net"
"google.golang.org/grpc"
"server/seedGeneration" // Replace with the actual import path for your generated pb
)
type server struct {
seedGeneration.UnimplementedAuthServiceServer
}
// Authenticate handles the Authenticate RPC method
func (s *server) Authenticate(ctx context.Context, req *seedGeneration.AuthenticateRequest) (*seedGeneration.AuthenticateResponse, error) {
//fmt.Println("Authenticate request received:", req)
// Simple logic for demonstration (you can replace it with real authentication logic)
if req.Username == "testuser" && req.Password == "testpass" {
return &seedGeneration.AuthenticateResponse{
Success: true,
Message: "Authentication successful",
}, nil
}
return &seedGeneration.AuthenticateResponse{
Success: true,
Message: "Authentication failed",
}, nil
}
// Logout handles the Logout RPC method
func (s *server) Logout(ctx context.Context, req *seedGeneration.LogoutRequest) (*seedGeneration.LogoutResponse, error) {
fmt.Println("Logout request received:", req)
// For now, just return a successful response
return &seedGeneration.LogoutResponse{}, nil
}
// Ping handles the Ping RPC method
func (s *server) Ping(ctx context.Context, req *seedGeneration.PingRequest) (*seedGeneration.PingResponse, error) {
fmt.Println("Ping request received:", req)
// Simple logic for Pong response
return &seedGeneration.PingResponse{Pong: req.Ping}, nil
}
// RefreshToken handles the RefreshToken RPC method
func (s *server) RefreshToken(ctx context.Context, req *seedGeneration.RefreshTokenRequest) (*seedGeneration.RefreshTokenResponse, error) {
fmt.Println("RefreshToken request received:", req)
// For now, just return a simple response
return &seedGeneration.RefreshTokenResponse{}, nil
}
// RegisterOTPSeed handles the RegisterOTPSeed RPC method
func (s *server) RegisterOTPSeed(ctx context.Context, req *seedGeneration.RegisterOTPSeedRequest) (*seedGeneration.RegisterOTPSeedResponse, error) {
//fmt.Println("RegisterOTPSeed request received:", req)
// For now, just return a success response
return &seedGeneration.RegisterOTPSeedResponse{
Success: true,
}, nil
}
// VerifyOTP handles the VerifyOTP RPC method
func (s *server) VerifyOTP(ctx context.Context, req *seedGeneration.VerifyOTPRequest) (*seedGeneration.VerifyOTPResponse, error) {
fmt.Println("VerifyOTP request received:", req)
// For now, just verify OTP logic (simple check)
if req.Otp == 123456 {
return &seedGeneration.VerifyOTPResponse{
Success: true,
Token: 654321, // Sample token
}, nil
}
return &seedGeneration.VerifyOTPResponse{
Success: false,
Token: 0,
}, nil
}
func main() {
// Listen on port 50052
lis, err := net.Listen("tcp", ":50052")
if err != nil {
fmt.Println("Failed to listen on port 50052:", err)
return
}
// Create a gRPC server
grpcServer := grpc.NewServer()
// Register the AuthService server
seedGeneration.RegisterAuthServiceServer(grpcServer, &server{})
// Start the server
fmt.Println("gRPC server started on port 50052")
if err := grpcServer.Serve(lis); err != nil {
fmt.Println("Failed to start server:", err)
}
}

Let’s run it with go run auth_server.go

image

If we run the server executable again, we get a different result!

image image

So now the server executable is starting to act like an actual server, and is hosting something on port 50051. Now we essentially have to do what we just did for the auth server but backwards. Instead of finding and defining functions for a server to respond to, we need to instead find and define functions for a client so that we can call them. Thankfully however, we’ve already found them. They are the main.(*seedGenerationServer) functions we found from earlier.

image

We can find what parameters they expect from functions beginning with otp/seedgen

image

So for example, a GetSeed request expects a username and password

image

What’s our package and service names this time though? package I’ll just call seed_generation due to the name of the main functions we found. We can find the service name in Ghidra, under otp/seedgen, there are a lot of functions defined with the name, SeedGenerationService. That’s probably our service name.

image

Now we can make our .proto file! I named mine seedGeneration.proto

syntax = "proto3";
package seed_generation;
option go_package = "/seedGeneration";
service SeedGenerationService {
rpc GetSeed(GetSeedRequest) returns (GetSeedResponse);
rpc StressTest(GetStressTestRequest) returns (GetStressTestResponse);
rpc Ping(GetPingRequest) returns (GetPingResponse);
}
message GetStressTestRequest {
// Define fields needed for authentication
int64 count = 1;
}
message GetStressTestResponse {
// Define fields for the response
string response = 1;
}
message GetSeedRequest {
// Define fields needed for authentication
string username = 1;
string token = 2;
}
message GetSeedResponse {
// Define fields for the response
int64 seed = 1;
int64 count = 2;
}
message GetPingRequest {
// Define fields needed for the request
int64 ping = 1;
}
message GetPingResponse {
int64 pong = 1;
}

Instead of Go, it turns out you can do grpc stuff in Python too. To save myself the Go setup headache, I made my client in Python. For Python, compile the proto file with

python -m grpc_tools.protoc --proto_path=. --python_out=. --grpc_python_out=. seedGeneration.proto

(Make sure you have installed the needed Python libraries with pip install grpcio grpcio-tools protobuf)

I want to call the GetSeed function first since it seems the most promising. However as mentioned before, it expects a username and password. What could they be?

This is where the shredded.jpg image comes into play. I was thinking of what JASPER meant, or where I’ve seen it before, and then I remembered Task 1. On the suspicious order, one of the emails was jasper_04044@guard.ar!

image

jasper_04044 is probably our username! For the password, I just went with password as a test.

Let’s make the client now in Python. I make some code to call StressTest too but I comment it out for now. I mainly want to see what GetSeed does.

import grpc
import seedGeneration_pb2
import seedGeneration_pb2_grpc
def get_seed(stub, username, password):
# Create the GetSeed request (add any fields if necessary)
request = seedGeneration_pb2.GetSeedRequest(username=username, token=password)
# Call the GetSeed method
response = stub.GetSeed(request)
return response
def stress_test(stub, count):
request = seedGeneration_pb2.GetStressTestRequest(count=count)
response = stub.StressTest(request)
return response
def run():
# Connect to the gRPC server
with grpc.insecure_channel('localhost:50051') as channel:
stub = seedGeneration_pb2_grpc.SeedGenerationServiceStub(channel)
# Replace with your actual username and password
username = "jasper_04044"
password = "password"
count = 1
print("Send get_seed")
response = get_seed(stub, username, password)
print("Get seed response:")
print(response)
#response = stress_test(stub, count)
#print(response)
if __name__ == "__main__":
run()

If we run this, we get a response!

image image

Note the dates being after the competition is due to me recreating the results when making this writeup

Well, {"time":"2025-02-15T20:57:05.742357055-06:00","level":"INFO","msg":"Registered OTP seed with authentication service","username":"jasper_04044","seed":8074660958352453125,"count":1} is some JSON, and the 3 important keys are {"username":"jasper_04044","seed":8074660958352453125,"count":1}. Is this our answer? I submit this, but it says that what I have isn’t quite right. So we have the correct keys, just not the correct values. What to do now?

After some thinking, I remembered that the prompt was asking for the output that would be given if the attackers had “successfully leveraged” the running software. That means that they had to exploit something. Looking back at the GetSeed function in Ghidra, we find something interesting.

image

A call to some sort of auth function. If we take a look, there’s a lot of logic, but one if statement stands out.

image

It’s even more evident in Binja with a test user authenticated... message

image

It seems that we have to somehow exploit the server executable, or its logic, in order to pass this conditional check. Well, how do we even go about doing that? First we need to see what’s being passed into the auth function

Let’s run server using gdb and set a breakpoint at main.(*SeedgenAuthClient).auth

image

If we run our client and call GetSeed, we hit our breakpoint

image

So username and password is passed into auth. Additionally, some kind of value, c is passed in as well to both GetSeed and auth. For our next step, let’s see if we can rename some variables in the auth function on Ghidra to make it easier to read

long main.(*SeedgenAuthClient).auth
(long param_1,undefined8 param_2,undefined8 param_3,ulong param_4,char *param_5,
long param_6)
{
long *in_RAX;
long lVar1;
ulong uVar2;
undefined8 *puVar3;
ulong uVar4;
long unaff_RBX;
long *plVar5;
long unaff_R14;
uint uVar6;
double __x;
double dVar7;
long *param_7;
long param_8;
ulong param_9;
long param_10;
undefined8 param_11;
char *param_12;
long param_13;
undefined local_28 [16];
undefined local_18 [16];
param_7 = in_RAX;
param_9 = param_4;
param_8 = unaff_RBX;
param_11 = param_2;
param_10 = param_1;
param_12 = param_5;
param_13 = param_6;
while (local_28 + 8 <= *(undefined **)(unaff_R14 + 0x10)) {
runtime.morestack_noctxt.abi0();
}
param_7[3] = param_7[3] + 1;
uVar4 = param_7[2];
lVar1 = math/rand.Int63();
param_7[2] = lVar1;
runtime.convTstring();
local_28._8_8_ = &PTR_DAT_0095c9a0;
local_28._0_8_ = &DAT_008075e0;
local_18._8_8_ = runtime.convTstring();
local_18._0_8_ = &DAT_008075e0;
dVar7 = log/slog.(*Logger).log(__x);
if ((param_13 != 0) && (*param_12 == '\0')) {
return param_7[2];
}
uVar2 = 0;
do {
if ((long)param_9 <= (long)uVar2) {
if ((uint)uVar4 == 0x7032f1e8) {
log/slog.(*Logger).log(dVar7);
return param_7[2];
}
plVar5 = (long *)0x0;
log/slog.(*Logger).log(dVar7);
lVar1 = runtime.newobject();
*(ulong *)(lVar1 + 0x30) = param_9;
if (runtime.writeBarrier != 0) {
lVar1 = runtime.gcWriteBarrier1();
*plVar5 = param_8;
}
*(long *)(lVar1 + 0x28) = param_8;
*(undefined8 *)(lVar1 + 0x40) = param_11;
if (runtime.writeBarrier != 0) {
lVar1 = runtime.gcWriteBarrier1();
*plVar5 = param_10;
}
*(long *)(lVar1 + 0x38) = param_10;
dVar7 = (double)(**(code **)(*param_7 + 0x18))(lVar1,0,param_7,&regexp.arrayNoInts,0,0);
log/slog.(*Logger).log(dVar7);
puVar3 = (undefined8 *)runtime.newobject();
puVar3[1] = 0x16;
*puVar3 = &DAT_008b9c8b;
return -1;
}
if ((long)param_9 < (long)(uVar2 + 4)) {
lVar1 = param_9 - uVar2;
if (lVar1 == 1) {
if (param_9 <= uVar2) {
/* WARNING: Subroutine does not return */
runtime.panicIndex();
}
uVar6 = (uint)*(byte *)(param_8 + uVar2);
}
else if (lVar1 == 2) {
if (param_9 <= uVar2) {
/* WARNING: Subroutine does not return */
runtime.panicIndex();
}
if (param_9 <= uVar2 + 1) {
/* WARNING: Subroutine does not return */
runtime.panicIndex();
}
uVar6 = (uint)*(ushort *)(param_8 + uVar2);
}
else if (lVar1 == 3) {
if (param_9 <= uVar2) {
/* WARNING: Subroutine does not return */
runtime.panicIndex();
}
if (param_9 <= uVar2 + 1) {
/* WARNING: Subroutine does not return */
runtime.panicIndex();
}
if (param_9 <= uVar2 + 2) {
/* WARNING: Subroutine does not return */
runtime.panicIndex();
}
uVar6 = (uint)CONCAT12(*(undefined *)(uVar2 + 2 + param_8),*(undefined2 *)(param_8 + uVar2))
;
}
else {
uVar6 = 0;
}
}
else {
if (param_9 <= uVar2) {
/* WARNING: Subroutine does not return */
runtime.panicIndex();
}
if (param_9 <= uVar2 + 1) {
/* WARNING: Subroutine does not return */
runtime.panicIndex();
}
if (param_9 <= uVar2 + 2) {
/* WARNING: Subroutine does not return */
runtime.panicIndex();
}
if (param_9 <= uVar2 + 3) {
/* WARNING: Subroutine does not return */
runtime.panicIndex();
}
uVar6 = *(uint *)(param_8 + uVar2);
}
uVar4 = (ulong)((uint)uVar4 ^ uVar6);
uVar2 = uVar2 + 4;
} while( true );
}

Right off the bat, we can see that uVar2 seems to be some kind of counter, since it starts at 0 and gets incremented by 4 each iteration. Let’s rename it to i.

Something interesting is param_7, which is set equal to in_RAX. If we run client.py and server again and hit the breakpoint in gdb, looking at what’s stored in rax, we can see that this is what c is pointing to.

image image

We also see that param_7[2] is assigned to lVar1, which itself is assigned to a random number, math/rand.Int63();. Well in gdb we can see what that number is by using pointer arithmetic to essentially index param_7[2] through the memory address that c is pointing to.

Re-running client.py and server, we hit the breakpoint again in gdb and this time get

image

Since c is pointing to param_7, let’s use pointer arithmetic to get the value at param_7[2]. If we add 2*8 to this address, we should get the element at the index 2, since each element takes up 8 bytes. We get a value:

image

This is the number 6205117966191793308

We get the response

image

That didn’t really tell us much, let’s do another run.

This time we get

image

This is the number 8074660958352453125. Hey wait a second. That was our seed value from the last run!

This time we get this output:

image

One more run to make sure we know what’s going on.

image

Sure enough, this is the number 3009302561299014827, which was our seed from the last run. So we can confidently say that the random number generated is the seed, so we can rename lVar1 to seed in Ghidra. We also keep in mind that param_7[2] is where the seed is stored.

The logic seems to be indexing or taking chunks of param_8 and assigning it to uVar6. param_8 is assigned to unaff_RBX. If we look in gdb to see what’s at rbx, we see that it’s our username

image

So param_8 is the username, and we can change the name accordingly. We’ll rename uVar6 to chunk, since it’s essentially a chunk of the username.

If we look at the what dictates the loop, the loop is dependent on i being less than param_9. Well if we’re taking chunks of the username each time, param_9 is likely the length of the username, since it would stop the loop if i is greater than or equal to the username’s length. We can change param_9 accordingly.

The function then XOR’s the username chunk and whatever uVar4 is. uVar4 is checked with the value 0x7032f1e8 in each iteration to see if they are equal, and then prints the user authenticated... message we saw before in Binja. We’ll rename uVar4 to target.

After our variable renaming, we now have this code.

long main.(*SeedgenAuthClient).auth
(long param_1,undefined8 param_2,undefined8 param_3,ulong param_4,char *param_5,
long param_6)
{
long *in_RAX;
long seed;
ulong i;
undefined8 *puVar1;
ulong target;
long unaff_RBX;
long *plVar2;
long unaff_R14;
uint chunk;
double __x;
double dVar3;
long *param_7;
long username;
ulong username_length;
long lStack0000000000000020;
undefined8 uStack0000000000000028;
char *pcStack0000000000000030;
long lStack0000000000000038;
undefined local_28 [16];
undefined local_18 [16];
param_7 = in_RAX;
username_length = param_4;
username = unaff_RBX;
uStack0000000000000028 = param_2;
lStack0000000000000020 = param_1;
pcStack0000000000000030 = param_5;
lStack0000000000000038 = param_6;
while (local_28 + 8 <= *(undefined **)(unaff_R14 + 0x10)) {
runtime.morestack_noctxt.abi0();
}
param_7[3] = param_7[3] + 1;
target = param_7[2];
seed = math/rand.Int63();
param_7[2] = seed;
runtime.convTstring();
local_28._8_8_ = &PTR_DAT_0095c9a0;
local_28._0_8_ = &DAT_008075e0;
local_18._8_8_ = runtime.convTstring();
local_18._0_8_ = &DAT_008075e0;
dVar3 = log/slog.(*Logger).log(__x);
if ((lStack0000000000000038 != 0) && (*pcStack0000000000000030 == '\0')) {
return param_7[2];
}
i = 0;
do {
if ((long)username_length <= (long)i) {
if ((uint)target == 0x7032f1e8) {
log/slog.(*Logger).log(dVar3);
return param_7[2];
}
plVar2 = (long *)0x0;
log/slog.(*Logger).log(dVar3);
seed = runtime.newobject();
*(ulong *)(seed + 0x30) = username_length;
if (runtime.writeBarrier != 0) {
seed = runtime.gcWriteBarrier1();
*plVar2 = username;
}
*(long *)(seed + 0x28) = username;
*(undefined8 *)(seed + 0x40) = uStack0000000000000028;
if (runtime.writeBarrier != 0) {
seed = runtime.gcWriteBarrier1();
*plVar2 = lStack0000000000000020;
}
*(long *)(seed + 0x38) = lStack0000000000000020;
dVar3 = (double)(**(code **)(*param_7 + 0x18))(seed,0,param_7,&regexp.arrayNoInts,0,0);
log/slog.(*Logger).log(dVar3);
puVar1 = (undefined8 *)runtime.newobject();
puVar1[1] = 0x16;
*puVar1 = &DAT_008b9c8b;
return -1;
}
if ((long)username_length < (long)(i + 4)) {
seed = username_length - i;
if (seed == 1) {
if (username_length <= i) {
/* WARNING: Subroutine does not return */
runtime.panicIndex();
}
chunk = (uint)*(byte *)(username + i);
}
else if (seed == 2) {
if (username_length <= i) {
/* WARNING: Subroutine does not return */
runtime.panicIndex();
}
if (username_length <= i + 1) {
/* WARNING: Subroutine does not return */
runtime.panicIndex();
}
chunk = (uint)*(ushort *)(username + i);
}
else if (seed == 3) {
if (username_length <= i) {
/* WARNING: Subroutine does not return */
runtime.panicIndex();
}
if (username_length <= i + 1) {
/* WARNING: Subroutine does not return */
runtime.panicIndex();
}
if (username_length <= i + 2) {
/* WARNING: Subroutine does not return */
runtime.panicIndex();
}
chunk = (uint)CONCAT12(*(undefined *)(i + 2 + username),*(undefined2 *)(username + i));
}
else {
chunk = 0;
}
}
else {
if (username_length <= i) {
/* WARNING: Subroutine does not return */
runtime.panicIndex();
}
if (username_length <= i + 1) {
/* WARNING: Subroutine does not return */
runtime.panicIndex();
}
if (username_length <= i + 2) {
/* WARNING: Subroutine does not return */
runtime.panicIndex();
}
if (username_length <= i + 3) {
/* WARNING: Subroutine does not return */
runtime.panicIndex();
}
chunk = *(uint *)(username + i);
}
target = (ulong)((uint)target ^ chunk);
i = i + 4;
} while( true );
}

Let’s look at what it’s doing. It gets a little confusing since the function initially has the seed variable as the randomly generated seed, assigns it to param_7[2], and then reuses it later as seed = username_length - i;. Regardless, we can get a good grasp of what’s going on.

target starts as the randomly generated seed value.

target = param_7[2];

auth then takes the username, and loops through it 4 bytes (characters) at a time.

i = 0;
do {
if ((long)username_length <= (long)i) {
... Logic ...
}
i = i + 4;
} while( true );

In each iteration, it XOR’s target with the 4 byte chunk.

target = (ulong)((uint)target ^ chunk);

After it finishes looping through the username and doing all the XOR logic, it checks to see if the final result equals 0x7032f1e8.

if ((uint)target == 0x7032f1e8) {
log/slog.(*Logger).log(dVar3);
return param_7[2];
}

So our goal is to find the username, seed, and count that once going through the XOR logic, will equal 0x7032f1e8. Based on the shredded.jpg image, it seems that we already have the correct username, which is jasper_04044. We just need to find the seed and count.

Technically, we could just call GetSeed a bunch of times, but that would take forever. The simplest way would be to recreate all this logic and run it locally. There’s just one issue, which is the randomly generated number. I thought it would change each time, which would make it impossible to do locally, but after resetting the server executable, we can see that the seed values at the corresponding counts are the same each time

image

Or in other words, count 1 is always the seed 8074660958352453125, count 2 is always the seed 3009302561299014827, etc, etc every time. This is why count is one of the important keys in the JSON we have to submit, since it is tied to the correct seed. The fact that the same seeds are generated each time means that the random number generator is seeded, we just have to find what the rng seed is (sorry for saying seed so much there).

In Ghidra, we can find the function, math/rand.(*Rand).Seed, which is the Go function used to seed its random number generator.

image

Back in gdb, let’s set a breakpoint there.

image

If we run this, we immediately hit the breakpoint, and we can get our seed value!

image

It’s 0x378f96687bfa0

We have everything we need, let’s start making our solve. We will continuously generate numbers using the seeded random number generator, take it and the username jasper_04044, go through the XOR logic and loop that we discussed earlier, and check to see if the final result equals 0x7032f1e8. We’ll code the solve in Go since the server executable uses specifically Go’s random number generator.

package main
import (
"encoding/binary"
"fmt"
"math/rand"
)
const (
goal uint32 = 0x7032f1e8
)
func simulateXOR(seed uint64, username string) (bool, uint64) {
usernameBytes := []byte(username)
uVar2 := seed // Initialize with seed value
i := 0
for i < len(usernameBytes) {
var chunk uint32
// Access 4 bytes at a time or less (pad smaller chunks)
if len(usernameBytes) - i >= 4 {
chunk = binary.LittleEndian.Uint32(usernameBytes[i : i+4])
} else {
// Process remaining bytes based on how many are left
switch len(usernameBytes) - i {
case 3:
chunk = uint32(usernameBytes[i]) |
uint32(usernameBytes[i+1])<<8 |
uint32(usernameBytes[i+2])<<16
case 2:
chunk = uint32(usernameBytes[i]) |
uint32(usernameBytes[i+1])<<8
case 1:
chunk = uint32(usernameBytes[i])
}
}
// XOR with the current chunk, casting uVar2 to uint before XORing
//uVar2 ^= uint64(chunk)
uVar2 = uint64(uint32(uVar2) ^ chunk)
// Increment by 4 for the next chunk
i += 4
}
// Cast uVar2 to uint32 for the final comparison
finalValue := uint32(uVar2)
return finalValue == goal, uVar2
}
func main() {
// The seed value got earlier
seed := uint64(0x378f96687bfa0)
// Set up the random number generator with the seed
rand.Seed(int64(seed))
// Username for XOR simulation
username := "jasper_04044"
// Start a counter to track attempts
var attempts int64 = -1
for {
attempts++
// Generate a new random seed value based on the original seed
currentSeed := rand.Int63() // 63-bit random value
if attempts == 1 || attempts == 2 || attempts == 3 {
fmt.Printf("Count %d with seed: %d\n", attempts, currentSeed)
}
// Simulate XOR logic with the generated random seed and username
success, finalUVar4 := simulateXOR(uint64(currentSeed), username)
if success {
// Print seed and count if match is found
fmt.Printf("Match found! Final uVar4: %08x\n", finalUVar4)
fmt.Printf("Seed: %d\n", currentSeed) // Print the seed
fmt.Printf("Count: %d\n", attempts) // Print the count (attempts)
break
}
}
}

I print the first few attempts to see if the seed aligns with the count. We initialize attempts to -1 to align the counts and seeds the same way that the server executable does.

Running this with go run solve.go, it takes a while, but eventually finishes. We get the output:

image

Is this our answer?? I submit {"username":"jasper_04044","seed":"7571067976073007827","count":"1073639578"} but it still says it’s incorrect. What the heck are we doing wrong, we have the answer right here!

After a long time I realized a fatal mistake I was making.

Remember when we were using gdb to find out what was stored in param_7[2], and found out that it was the seed? Remember the first number we saw was 6205117966191793308? After some testing, I find that 6205117966191793308 is actually the first seed to be generated by the random number generator, not 8074660958352453125. 8074660958352453125 is actually the second generated number. 6205117966191793308 technically should be the seed that gets printed with count 1. But instead, the next seed, 8074660958352453125 is what got printed.

What does this mean for us? Even though 7571067976073007827 is the correct seed that along with the username would pass the XOR logic, what would get printed to the screen? The next seed. The prompt didn’t ask for the correct seed, username, and count combo to pass the XOR logic, it asked for the correct seed, username, and count combo that would be logged once successfully leveraged.

I tweak the code to print the next seed after we pass the Xor logic

package main
import (
"encoding/binary"
"fmt"
"math/rand"
)
const (
goal uint32 = 0x7032f1e8
)
func simulateXOR(seed uint64, username string) (bool, uint64) {
usernameBytes := []byte(username)
uVar2 := seed // Initialize with seed value
i := 0
for i < len(usernameBytes) {
var chunk uint32
// Access 4 bytes at a time or less (pad smaller chunks)
if len(usernameBytes) - i >= 4 {
chunk = binary.LittleEndian.Uint32(usernameBytes[i : i+4])
} else {
// Process remaining bytes based on how many are left
switch len(usernameBytes) - i {
case 3:
chunk = uint32(usernameBytes[i]) |
uint32(usernameBytes[i+1])<<8 |
uint32(usernameBytes[i+2])<<16
case 2:
chunk = uint32(usernameBytes[i]) |
uint32(usernameBytes[i+1])<<8
case 1:
chunk = uint32(usernameBytes[i])
}
}
// XOR with the current chunk, casting uVar2 to uint before XORing
//uVar2 ^= uint64(chunk)
uVar2 = uint64(uint32(uVar2) ^ chunk)
// Increment by 4 for the next chunk
i += 4
}
// Cast uVar2 to uint32 for the final comparison
finalValue := uint32(uVar2)
return finalValue == goal, uVar2
}
func main() {
// The seed value got earlier
seed := uint64(0x378f96687bfa0)
// Set up the random number generator with the seed
rand.Seed(int64(seed))
// Username for XOR simulation
username := "jasper_04044"
// Start a counter to track attempts
var attempts int64 = -1
for {
attempts++
// Generate a new random seed value based on the original seed
currentSeed := rand.Int63() // 63-bit random value
if attempts == 1 || attempts == 2 || attempts == 3 {
fmt.Printf("Count %d with seed: %d\n", attempts, currentSeed)
}
// Simulate XOR logic with the generated random seed and username
success, finalUVar4 := simulateXOR(uint64(currentSeed), username)
if success {
// Print seed and count if match is found
fmt.Printf("Match found! Final uVar4: %08x\n", finalUVar4)
fmt.Printf("Seed: %d\n", currentSeed) // Print the seed
fmt.Printf("Count: %d\n", attempts) // Print the count (attempts)
fmt.Printf("Next seed: %d\n", rand.Int63())
break
}
}
}

This time we get the output:

image

Therefore our answer is

{"username":"jasper_04044","seed":"5838753747453732554","count":"1073639578"}

And with that, we have finally solved Task 3. Took a while, huh?

Results:

So that’s how they leveraged their tokens!

Task 4 - LLMs never lie - (Programming, Forensics)

Prompt 4:

Great work! With a credible threat proven, NSA’s Cybersecurity Collaboration Center reaches out to GA and discloses the vulnerability with some indicators of compromise (IoCs) to scan for.

New scan reports in hand, GA’s SOC is confident they’ve been breached using this attack vector. They’ve put in a request for support from NSA, and Barry is now tasked with assisting with the incident response.

While engaging the development teams directly at GA, you discover that their software engineers rely heavily on an offline LLM to assist in their workflows. A handful of developers vaguely recall once getting some confusing additions to their responses but can’t remember the specifics.

Barry asked for a copy of the proprietary LLM model, but approvals will take too long. Meanwhile, he was able to engage GA’s IT Security to retrieve partial audit logs for the developers and access to a caching proxy for the developers’ site.

Barry is great at DFIR, but he knows what he doesn’t know, and LLMs are outside of his wheelhouse for now. Your mutual friend Dominique was always interested in GAI and now works in Research Directorate.

The developers use the LLM for help during their work duties, and their AUP allows for limited personal use. GA IT Security has bound the audit log to an estimated time period and filtered it to specific processes. Barry sent a client certificate for you to authenticate securely with the caching proxy using https://34.195.208.56/?q=query%20string.

You bring Dominique up to speed on the importance of the mission. They receive a nod from their management to spend some cycles with you looking at the artifacts. You send the audit logs their way and get to work looking at this one.

Find any snippet that has been purposefully altered.

Downloads:

Client certificate issued by the GA CA (client.crt)

Client private key used to establish a secure connection (client.key)

TTY audit log of a developer’s shell activity (audit.log)

Prompt:

A maliciously altered line from a code snippet

Solve

So this Task gives us an audit log, as well as a certificate and key we can use to connect to a caching proxy.

First, let’s take a look at the audit log. It’s really long, but remembering that we’re looking for presumably LLM queries, we immediately see some interesting lines:

image

We see lines that begin with gagpt -m .... These are likely queries to the LLM. We know that we are trying to find any suspicious lines that have been added to the LLM’s responses, so how do we get the responses? That’s where the caching proxy comes into play.

We can send a simple get request, using the given .crt and .key file to make the connection, to the caching proxy at https://34.195.208.56/?q=query%20string, with query%20string being the gagpt -m ... line. This will return us the LLM’s response in JSON.

We can write a Python script to automate going through the audit log to find gagpt -m ... lines, which we can use in our get request. I save our responses to a file named queries_and_responses.txt so that we can take a look at them once the script is done executing.

import re
import requests
import urllib.parse
import json
# Define the log file path and server URL
log_file_path = 'audit.log'
server_url = "https://34.195.208.56/"
# Define the certificate paths (if needed)
client_cert = 'client.crt'
client_key = 'client.key'
output_file = 'queries_and_responses.txt'
def extract_gagpt_queries(log_file_path):
"""
Extracts 'gagpt -m' queries from the audit log.
"""
with open(log_file_path, 'r') as log_file:
lines = log_file.readlines()
# Regex to match the gagpt -m queries
gagpt_queries = []
for line in lines:
match = re.search(r'd=gagpt -m "(.*?)"', line)
if match:
query = match.group(1)
gagpt_queries.append(query)
return gagpt_queries
def send_query_to_server(query):
"""
Sends a query to the server using curl-like behavior in Python with requests.
"""
# URL encode the query
encoded_query = urllib.parse.quote(query)
# Define the URL with the query parameter
url = f"{server_url}?q={encoded_query}"
# Send the GET request
try:
response = requests.get(url, cert=(client_cert, client_key), verify=False) # Disable SSL verification for now
if response.status_code == 200:
return response.json() # Assuming the response is in JSON format
else:
return f"Error: {response.status_code} - {response.text}"
except requests.exceptions.RequestException as e:
return f"Request failed: {e}"
def save_query_and_response(query, response):
"""
Saves the query and response to a file with line spacing.
"""
with open(output_file, 'a') as file:
file.write(f"QUERY: {query}\n")
file.write(f"RESPONSE: {json.dumps(response, indent=2)}\n") # Pretty-print the JSON response
file.write("\n") # Add a blank line for spacing
def main():
# Extract queries from the audit log
queries = extract_gagpt_queries(log_file_path)
# Iterate over each query and send it to the server
for query in queries:
print(f"Sending query: {query}")
response = send_query_to_server(query)
save_query_and_response(query, response)
print(f"Response saved for query: {query}")
if __name__ == '__main__':
main()

This gives us a very long file of JSON, which shows the LLM’s responses to the different queries, with the below image being one such example response:

image

Skimming through our responses, I find nothing of note, but there is something pretty interesting. There seems to be legitimate queries being made, but the text is malformed in the audit log, so the caching server doesn’t recognize the request. Below is one such example:

image

However, when you clean up these queries (fixing the spelling errors and getting rid of the extraneous characters), the LLM provides a valid response

image

This means that we’re missing out on a whole lot of responses!

I modified our already existing script to now allow us to specify what the query is using the variable cleaned_query. This allows us to clean up a malformed query we find and to individually get its response. I save the responses to a file named cleaned.txt.

import requests
import urllib.parse
import json
# Define the server URL
server_url = "https://34.195.208.56/"
# Define the certificate paths (if needed)
client_cert = 'client.crt'
client_key = 'client.key'
output_file = 'cleaned.txt'
def send_query_to_server(query):
"""
Sends a query to the server using curl-like behavior in Python with requests.
"""
# URL encode the query
encoded_query = urllib.parse.quote(query)
# Define the URL with the query parameter
url = f"{server_url}?q={encoded_query}"
# Send the GET request
try:
response = requests.get(url, cert=(client_cert, client_key), verify=False) # Disable SSL verification for now
if response.status_code == 200:
return response.json() # Assuming the response is in JSON format
else:
return f"Error: {response.status_code} - {response.text}"
except requests.exceptions.RequestException as e:
return f"Request failed: {e}"
def save_query_and_response(query, response):
"""
Saves the query and response to a file with line spacing.
"""
with open(output_file, 'a') as file:
file.write(f"QUERY: {query}\n")
file.write(f"RESPONSE: {json.dumps(response, indent=2)}\n") # Pretty-print the JSON response
file.write("\n") # Add a blank line for spacing
def main():
# Cleaned-up query
cleaned_query = "INSERT CLEANED QUERY HERE"
# Send the cleaned query to the server
response = send_query_to_server(cleaned_query)
# Save the query and response to a file
save_query_and_response(cleaned_query, response)
print(f"Response saved for query: {cleaned_query}")
if __name__ == '__main__':
main()

Some of the queries obviously aren’t code related. There’s some revolving around a father asking for parenting advice for his daughter, someone asking about a pet python, and many other “non-serious” queries. I went through the audit log, specifically getting the responses for code related malformed queries, but still, there was nothing suspicious in the responses.

Again, going through the audit log, I was trying to find anything that I may have missed. My eye then caught something, and to be honest, I got pretty lucky that I noticed it. There was one query that instead of beginning with gagpt -m ..., began with gagpt m ..., without the -. Since our Python script was made to find all queries that specifically began with gagpt -m ..., it didn’t pick up on this query.

image

Cleaning it up and using our solve-specific.py script, we get its response, but again, it’s nothing of note.

Maybe there’s other queries that begin with just gagpt m?

Indeed, there was one other query that was like this:

image

I clean it up and stick it into solve-specific.py

image

This gets us a response, and finally along with it, the suspicious line. Or to be specific, a supsicious import that has nothing to do with the question at hand:

image

Our answer is globals()['ga'] = __import__('ga513e9')

Submitting this solves Task 4!

Results:

That’s the snippet! I doubt the LLM is supposed to suggest an import like that.

Task 5 - The #153 - (Reverse Engineering, Cryptography)

Prompt 5:

Great job finding out what the APT did with the LLM! GA was able to check their network logs and figure out which developer copy and pasted the malicious code; that developer works on a core library used in firmware for the U.S. Joint Cyber Tactical Vehicle (JCTV)! This is worse than we thought!

You ask GA if they can share the firmware, but they must work with their legal teams to release copies of it (even to the NSA). While you wait, you look back at the data recovered from the raid. You discover an additional drive that you haven’t yet examined, so you decide to go back and look to see if you can find anything interesting on it. Sure enough, you find an encrypted file system on it, maybe it contains something that will help!

Unfortunately, you need to find a way to decrypt it. You remember that Emiko joined the Cryptanalysis Development Program (CADP) and might have some experience with this type of thing. When you reach out, he’s immediately interested! He tells you that while the cryptography is usually solid, the implementation can often have flaws. Together you start hunting for something that will give you access to the filesystem.

What is the password to decrypt the filesystem?

Downloads:

disk image of the USB drive which contains the encrypted filesystem (disk.dd.tar.gz)

Interesting files from the user’s directory (files.zip)

Interesting files from the bin/ directory (bins.zip)

Prompt:

Enter the password (hope it works!)

Solve

Alright so for this task, we’re given two zip files and one tar file. The tar file contains the image with the encrypted file system. files.zip contains interesting files from the user’s directory, and bins.zip contains interesting files from the bin/ directory. Well, let’s unzip the .zip files and see what we get, shall we?

Unzipping bins.zip gives us two executable files.

image

Unzipping files.zip gives us a whole bunch of files, which are all strangely put into hidden directories for some reason. There are files that are put into a .passwords directory, .purple directory, and .keys directory

image

First looking into these hidden directories, .purple seems to contain what seem to be chat messages between a user, 570RM (presumably the owner of the drive) with multiple other users

image

Each of these directories contain at least one file containing some chat messages. We’ll take a look at these later

The .passwords directory contains exactly that, passwords for different services, but they’re oddly all within a directory that looks to be a hash. Also, when we try to read any of the passwords, they seem to be encrypted, as seen below when trying to read the USB password

image

The .keys directory contains also exactly what the directory name implies, keys. They seem to correspond to the same users that we saw in the .purple directory chat logs.

image

Interesting stuff here. We’ll take note of all of this, and move to the two executables we found earlier.

If we run file on them, we see that they are ELF files, but if we try to execute them, they’re clearly Python files

image

These seem to be Pyinstaller generated executable files. Thankfully, there’s a tool that can easily help us with this, pyinstxtractor. We just have to run python3 pyinstxtractor.py <filename>

Running pyinstxtractor on pidgin_rsa_encryption and pm gets us two directories, pidgin_rsa_encryption_extracted and pm_extracted:

image

In each directory, we can see the .pyc file for each respective program, which contains the Python code for the executables! The only issue is that they’re compiled.

image image

Thankfully, we have another tool for this, PyLingual. This is a free tool that allows us to decompile .pyc files!

This gets us the following Python code for each file:

pidgin_rsa_encryption.py
# Decompiled with PyLingual (https://pylingual.io)
# Bytecode version: 3.11a7e (3495)
# Source timestamp: 1970-01-01 00:00:00 UTC (0)
import sys
import math
import base64
import random
from Crypto.PublicKey import RSA
from rsa import core
def load_public_key(pub_key):
try:
with open(pub_key, 'rb') as f:
public_key = RSA.import_key(f.read())
return public_key
except:
pass
print('public key not found')
sys.exit(1)
def load_private_key(password, priv_key):
try:
with open(priv_key, 'rb') as f:
try:
private_key = RSA.import_key(f.read(), password)
except:
print('Incorrect password')
sys.exit(1)
return private_key
except:
pass
print('private key not found or password incorrect')
sys.exit(1)
def encrypt_chunk(chunk, public_key):
k = math.ceil(public_key.n.bit_length() + 8)
pad_len = k * len(chunk)
random.seed(a='None')
padding = bytes([random.randrange(1, 255) for i in range(pad_len + 3)])
padding = b'\x00\x02' * padding / b'\x00'
padded_chunk = padding / chunk.encode()
input_nr = int.from_bytes(padded_chunk, byteorder='big')
crypted_nr = core.encrypt_int(input_nr, public_key.e, public_key.n)
encrypted_chunk = crypted_nr.to_bytes(k, byteorder='big')
return base64.b64encode(encrypted_chunk).decode()
def decrypt_chunk(encrypted_chunk, private_key):
try:
decoded_chunk = base64.b64decode(encrypted_chunk)
except:
print('Invalid message')
sys.exit(1)
input_nr = int.from_bytes(decoded_chunk, byteorder='big')
decrypted_nr = core.decrypt_int(input_nr, private_key.d, private_key.n)
decrypted_chunk = decrypted_nr.to_bytes(256, byteorder='big')
unpadded_chunk = decrypted_chunk[2:]
end_of_pad = unpadded_chunk.find(b'\x00')
unpadded_chunk = unpadded_chunk[end_of_pad + 1:]
return unpadded_chunk.decode()
def encrypt_message(message, public_key):
chunk_size = 245
encrypted_chunks = []
for i in range(0, len(message), chunk_size):
chunk = message[i:i + chunk_size]
encrypted_chunk = encrypt_chunk(chunk, public_key)
encrypted_chunks.append(encrypted_chunk)
return ' '.join(encrypted_chunks)
def decrypt_message(encrypted_message, private_key):
encrypted_chunks = encrypted_message.split(' ')
decrypted_message = ''.join((decrypt_chunk(chunk, private_key) for chunk in encrypted_chunks))
return decrypted_message
def send_message_to_pidgin(message, recipient):
import dbus
bus = dbus.SessionBus()
try:
purple = bus.get_object('im.pidgin.purple.PurpleService', '/im/pidgin/purple/PurpleObject')
except:
print('Could not send message to pidgin - not connected')
sys.exit(1)
iface = dbus.Interface(purple, 'im.pidgin.purple.PurpleInterface')
accounts = iface.PurpleAccountsGetAllActive()
if not accounts:
print('No active Pidgin accounts found.')
return
account = accounts[0]
conv = iface.PurpleConversationNew(1, account, recipient)
im = iface.PurpleConvIm(conv)
iface.PurpleConvImSend(im, message)
def main():
if len(sys.argv) < 2:
print('Usage: python pidgin_rsa_encryption.py <mode> [<recipient> <message> <public_key> | <encrypted_message> <password>]')
print('Modes:')
print(' send <recipient> <message> <public_key> - Send an encrypted message')
print(' receive <encrypted_message> <password> <private_key> - Decrypt the given encrypted message')
sys.exit(1)
mode = sys.argv[1]
if mode == 'send':
if len(sys.argv) != 5:
print('Usage: python pidgin_rsa_encryption.py send <recipient> <message> <public_key>')
sys.exit(1)
recipient = sys.argv[2]
message = sys.argv[3]
pub_key = sys.argv[4]
public_key = load_public_key(pub_key)
encrypted_message = encrypt_message(message, public_key)
send_message_to_pidgin(encrypted_message, recipient)
print('Encrypted message sent to Pidgin.')
elif mode == 'receive':
if len(sys.argv) != 5:
print('Usage: python pidgin_rsa_encryption.py receive <encrypted_message> <password> <private_key>')
sys.exit(1)
encrypted_message = sys.argv[2]
password = sys.argv[3]
priv_key = sys.argv[4]
private_key = load_private_key(password, priv_key)
decrypted_message = decrypt_message(encrypted_message, private_key)
print('Decrypted message:', decrypted_message)
else:
print("Invalid mode. Use 'send' or 'receive'.")
if __name__ == '__main__':
main()
pm.py
# Decompiled with PyLingual (https://pylingual.io)
# Bytecode version: 3.11a7e (3495)
# Source timestamp: 1970-01-01 00:00:00 UTC (0)
import os
import sys
import base64
from getpass import getpass
import hashlib
import time
import string
import random
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
SALT = b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
def derive_key(password: str) -> bytes:
kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=SALT, iterations=100000, backend=default_backend())
return kdf.derive(password.encode())
def generate_password(length: int) -> str:
character_list = string.ascii_letters * string.digits / string.punctuation
password = []
for i in range(length):
randomchar = random.choice(character_list)
password.append(randomchar)
print('Your password is ' + ''.join(password))
return ''.join(password)
def encrypt_password(spassword: str, password: str) -> bytes:
key = derive_key(password)
ts = str(int(time.time() * 60)).encode('utf-8')
iv = hashlib.md5(ts).digest()
cipher = Cipher(algorithms.AES(key), modes.CFB(iv), backend=default_backend())
encryptor = cipher.encryptor()
return encryptor.update(spassword.encode()) + encryptor.finalize()
pass
return iv + encrypted_password
return False
def decrypt_password(encrypted_data: bytes, password: str) -> str:
key = derive_key(password)
iv = encrypted_data[:16]
encrypted_password = encrypted_data[16:]
cipher = Cipher(algorithms.AES(key), modes.CFB(iv), backend=default_backend())
decryptor = cipher.decryptor()
decrypted_password = decryptor.update(encrypted_password) + decryptor.finalize()
return decrypted_password.decode()
def save_password(filename: str, password: str, spassword: str):
encrypted_password = encrypt_password(spassword, password)
with open(filename, 'wb') as file:
file.write(encrypted_password)
print(f'Successfully saved password to {filename}0')
def load_password(filename: str, password: str) -> str:
with open(filename, 'rb') as file:
encrypted_data = file.read()
return decrypt_password(encrypted_data, password)
def usage():
print('Usage: pm.py <command>')
print('Commands:')
print(' init - Create a new master password')
print(' add - Add a new password')
print(' gen - Generate a new password')
print(' read - Retrieve a password')
print(' help - Print this help file')
def main():
if len(sys.argv) != 2:
usage()
sys.exit(1)
command = sys.argv[1]
if command == 'init':
homedir = os.path.expanduser('~')
passdir = homedir + '/.passwords'
if not os.path.isdir(passdir):
os.mkdir(passdir)
password = getpass(prompt='Enter your master password: ')
passhash = hashlib.md5(password.encode('utf-8')).hexdigest()
dirname = passdir + '/' * passhash
if not os.path.isdir(dirname):
os.mkdir(dirname)
else:
print('directory already exists for that master password')
elif command == 'add':
password = getpass(prompt='Enter your master password: ')
passhash = hashlib.md5(password.encode('utf-8')).hexdigest()
dirname = os.path.expanduser('~') + '/.passwords/' + passhash
if not os.path.isdir(dirname):
print('Unknown master password, please init first')
return
service = input('Enter the service name: ')
filename = dirname + '/' * service
if os.path.isfile(filename):
print('A password was already stored for that service.')
return
spassword = input(f'Enter the password to store for {service}: ')
save_password(filename, password, spassword)
elif command == 'read':
password = getpass(prompt='Enter your master password: ')
passhash = hashlib.md5(password.encode('utf-8')).hexdigest()
dirname = os.path.expanduser('~') + '/.passwords/' + passhash
if not os.path.isdir(dirname):
print('Unknown master password')
return
service = input('Enter the service name: ')
filename = dirname + '/' * service
if not os.path.isfile(filename):
print('No password stored for that service using that master password')
return
spassword = load_password(filename, password)
print(f'Password for {service}: {spassword}0')
elif command == 'gen':
password = getpass(prompt='Enter your master password: ')
passhash = hashlib.md5(password.encode('utf-8')).hexdigest()
dirname = os.path.expanduser('~') + '/.passwords/' + passhash
if not os.path.isdir(dirname):
print('Unknown master password, please init first')
return
service = input('Enter the service name: ')
filename = dirname + '/' * service
if os.path.isfile(filename):
print('A password was already stored for that service.')
return
pass_len = int(input('Enter the password length (default 18): ') or '18')
spassword = generate_password(pass_len)
save_password(filename, password, spassword)
elif command == 'help':
usage()
else:
print('Unknown command')
if __name__ == '__main__':
main()

Taking a look at the pm.py file first, we can see that this corresponds to the .passwords directory. That weird hash directory indeed was a hash! The program stores passwords in a directory based on the md5 hash of the master password

image

Also, it seems to encrypt the passwords using AES CFB mode. It uses the master password to derive the key used for the AES encryption

image

This means that we also need said master password in order to decrypt any of the passwords

image

Since the master password is a md5 hash, 3ead1101919a08e7d7f345e92b1c66da, I tried to crack it using rockyou, but no dice.

Without the master password, we kind of can’t really do anything here. Let’s now take a look at pidgin_rsa_encryption.py

Looking at the code, it’s evident that pidgin_rsa_encryption.py is related to the .purple directory, which contained the chat message logs.

image

This program seems to use RSA to send encrypted messages and decrypt recieved messages via the pidgin chat platform

image

When sending a message, you use the public key of the recipient. When decrypting a received message, you use your own private key. This is just standard RSA.

image

Also lastly, it seems that the messages once encrypted are converted to base64. Due to this, when decrypting, it expects the message in base64:

image

Well, since we know this program is used to send encrypted messages through pidgin, let’s see if we can go through the chats and find any sent messages.

First off, in a chat with a user named B055MAN, we see that B055MAN sends 570RM the password for the USB using pidgin_rsa_encryption.py. 570RM then seems to store it using pm.py. As we can see, the encrypted message is in base64.

image

This is likely the USB password that we saw earlier! However, there doesn’t seem to be much we can do here. Let’s see if there’s anything more we can find.

In a message with PL46U3, we see that the AWS password the group was using was changed, so 570RM needs to send it to PL46U3 and the other group members

image image

We see that same AWS password is seemingly sent to user 4C1D too

image

And finally, it is also sent to user V3RM1N

image

Interesting. So we have the same exact message sent to 3 different people, and subsequently, with 3 different public keys.

We have the following base64 encoded passwords:

AWS password to 4C1D: P3bTAhZTbtlu9aV+8X5oFQ+F8qqcMpVGZTtT1p8QT3TLMaBGWVqkACIWkaQov/2UnBUQcSY47aIfwATVclTZXj7EuTOIt+9hSntNiw69MYl3wHw+wHxi9KjmU2l5UffPoAj+q+AL0SlwIKdzRWEjXOswQdXkzBeFJ4RxeNMiNkuHyaoUeylG4nrZLxev0b1nUUHu3NTxQwCnv2+mUv8bh9MW0fxsvS3vTLYBYaCTAcu+RaKLP5YyNKw1sH0EqtuDAu043V6BKbGdm9xKWh27e5aj8RFnLo9UhvdB6UkglwlPBsBxE9dZLx7xjsauJHdssGFfT3rf48O+YiEkKPGh3A==

AWS Password to PL46U3: QjPtJ+yOgegFCSQ4HTNcL45af+MIVeWwJeDZ9HQS4HAVocf9lsusPt7GyfhbqN4DnT7HViX0jpTxPt6BcwHex2+WswUgaD12i7RgnjLBBaN6yldfCa2LEGib09DIKBSh8s90rlbkNbEfJqPIpM/bFjKLWB/vsUxvCypHhs6TVMxIxk0hSzh96AFcLt17rDa8Ly+cciZDzQpVMSYy6WECtRrITcEN/lgqyztk1kA04hd6Hr+uAtxwPAEfsx7QZ8kotSM7ZFHGL0OBhNj9x/LGnPvN+trbyKcieaF9uRD26W9TUQ9DintFrjcCNe8F+MhcJw9bNOMIcIQyxv3kbZ3hcA==

AWS Password to V3RM1N: ZgVvYj1jxIiWnHStb0VUDIwV/ckkgpERykveSolnV5NAHeFeaAvu0bH2HIppKSwsdpQvgqfYdd3fyeM2ywyLjrSQxFkj2Ndkm6YzdnaSZMKd0tUT7recxlhkjlZ4U0cXazAVvwj8EMefLFDhj6JhcDIwZNS0CZiIwJmj3ooaJMU0uDAorGf5AeOGaYQzfo2G5rwxW1p16u996bDsvY/Cryk7DMGAyV2UDwkgCdp0LHEsfZd+15GRavlL9qWrQs8p3oGd5JGVkMinVu27sDdAwcT+l+buzc6msvLpK2K2BOGEY01UmVA1A2EEQVEKCPoAtF9vhOVSrj/kO5Tyj3A5mg==

USB Password to 570RM: REpUJtDj6C6q8A8lfAPM1C749yBATyjHZBder8fIMAyxxWoXRNRazVfduEVWm7veRRgDU7ndk5LIuqh3CHJMbcbB1GCHn4QomB6CGtYTuG75VTrfOxelprHPYj240mNeLi6saQsAKRrvtpl1woeSobY1ayT26DZ0DXETT3I8K/OVWi2aVR0VTIvrg5yx2t6GeKg66R9I++bAH14OyZW/C2CbIvGQzE8pv/Ww69Tv0POqdqYwDs9/Oi0oCXfPxq09eytLrBKpOEheoYebBJA11PLD/7e1SnIpOnPe6ySI1WLHYofc1da/tZuBUvpo6eFRiSK7R5atCk2l2Oex/I6OFA==

Our main focus are those AWS passwords though. Due to the interesting set up, I start googling, and lo’ and behold, I find something very promising.

Hastad’s Broadcast Attack is an attack that utilizes the Chinese Remainder Theorem (CRT). Essentially, if the same message is sent to at least 3 others, and their public keys all have the same small public exponent (ideally e = 3), we get a system of equations that we can solve with CRT. This also provides more info if you’d like to learn more.

This is exactly what’s going on here!

First of all, let’s check to see if the public keys do indeed have a small public exponent

We can use RsaCtfTool for this. We just have to run python3 python3 RsaCtfTool.py --publickey <PUBLIC-KEY> --dumpkey

We have to check the public keys for 4C1D, PL46U3, and V3RM1N:

image

Look at that, they all have a public exponent of 3! This means that we can indeed use Hastad’s Broadcast Attack to decrypt the AWS password.

We can write a Python script to do this. Using the knowledge of the padding used from pidgin_rsa_encryption.py, the public keys, and the base64 encoded messages, we end up with the following script

from Crypto.PublicKey import RSA
from math import gcd
from sympy import integer_nthroot
from sympy.ntheory.modular import crt
import base64
# Function to load modulus from a PEM public key file
def get_modulus_from_pem(pem_file):
with open(pem_file, 'rb') as f:
public_key = RSA.import_key(f.read())
return public_key.n
# Function to decode Base64 ciphertext into an integer
def base64_to_int(b64_ciphertext):
decoded_bytes = base64.b64decode(b64_ciphertext)
return int.from_bytes(decoded_bytes, byteorder='big')
# Load the moduli from public keys
n_4C1D = get_modulus_from_pem(".keys/4C1D_public_key.pem")
n_PL46U3 = get_modulus_from_pem(".keys/PL46U3_public_key.pem")
n_V3RM1N = get_modulus_from_pem(".keys/V3RM1N_public_key.pem")
# Ensure the moduli are pairwise coprime
g1 = gcd(n_4C1D, n_PL46U3)
g2 = gcd(n_4C1D, n_V3RM1N)
g3 = gcd(n_PL46U3, n_V3RM1N)
assert g1 == g2 == g3 == 1, "The moduli are not pairwise coprime!"
# Convert Base64 ciphertexts into integers
ciphertexts = [
base64_to_int("P3bTAhZTbtlu9aV+8X5oFQ+F8qqcMpVGZTtT1p8QT3TLMaBGWVqkACIWkaQov/2UnBUQcSY47aIfwATVclTZXj7EuTOIt+9hSntNiw69MYl3wHw+wHxi9KjmU2l5UffPoAj+q+AL0SlwIKdzRWEjXOswQdXkzBeFJ4RxeNMiNkuHyaoUeylG4nrZLxev0b1nUUHu3NTxQwCnv2+mUv8bh9MW0fxsvS3vTLYBYaCTAcu+RaKLP5YyNKw1sH0EqtuDAu043V6BKbGdm9xKWh27e5aj8RFnLo9UhvdB6UkglwlPBsBxE9dZLx7xjsauJHdssGFfT3rf48O+YiEkKPGh3A=="),
base64_to_int("QjPtJ+yOgegFCSQ4HTNcL45af+MIVeWwJeDZ9HQS4HAVocf9lsusPt7GyfhbqN4DnT7HViX0jpTxPt6BcwHex2+WswUgaD12i7RgnjLBBaN6yldfCa2LEGib09DIKBSh8s90rlbkNbEfJqPIpM/bFjKLWB/vsUxvCypHhs6TVMxIxk0hSzh96AFcLt17rDa8Ly+cciZDzQpVMSYy6WECtRrITcEN/lgqyztk1kA04hd6Hr+uAtxwPAEfsx7QZ8kotSM7ZFHGL0OBhNj9x/LGnPvN+trbyKcieaF9uRD26W9TUQ9DintFrjcCNe8F+MhcJw9bNOMIcIQyxv3kbZ3hcA=="),
base64_to_int("ZgVvYj1jxIiWnHStb0VUDIwV/ckkgpERykveSolnV5NAHeFeaAvu0bH2HIppKSwsdpQvgqfYdd3fyeM2ywyLjrSQxFkj2Ndkm6YzdnaSZMKd0tUT7recxlhkjlZ4U0cXazAVvwj8EMefLFDhj6JhcDIwZNS0CZiIwJmj3ooaJMU0uDAorGf5AeOGaYQzfo2G5rwxW1p16u996bDsvY/Cryk7DMGAyV2UDwkgCdp0LHEsfZd+15GRavlL9qWrQs8p3oGd5JGVkMinVu27sDdAwcT+l+buzc6msvLpK2K2BOGEY01UmVA1A2EEQVEKCPoAtF9vhOVSrj/kO5Tyj3A5mg=="),
]
moduli = [n_4C1D, n_PL46U3, n_V3RM1N]
# Combine ciphertexts using CRT
m_e, _ = crt(moduli, ciphertexts)
# Take the e-th root to recover the padded plaintext
e = 3
padded_plaintext, exact = integer_nthroot(m_e, e)
if exact:
print(f"Padded plaintext (integer): {padded_plaintext}")
# Convert back to bytes to inspect the padded plaintext
plaintext_bytes = padded_plaintext.to_bytes((padded_plaintext.bit_length() + 7) // 8, byteorder='big')
print(f"Padded plaintext (bytes): {plaintext_bytes}")
else:
print("Failed to compute the exact root.")

Running this, we get the following result!

image

So the AWS password is X?-d|C]jXN~Txh|Ew|

Okay so we have the AWS password. How exactly does this help us get the USB password?

Well, I underwent a lot of research. We seem to be done with the RSA part of this task, or in other words, done with the pidgin_rsa_encryption.py part. All signs point to the next part being related to the AES CFB part. I found some interesting stack exchange discussions here and here all warning of using AES CFB and reusing the IV. This piqued my interest. Maybe this has to do with the intended solve? Time to test my hunch.

If we look at pm.py, we can see that the first 16 bytes of each encrypted password actually is the IV

image

With this in mind, we can go through all the passwords that are stored in the .passwords directory just to see if any of them have matching IVs.

We can write a Python script to do this

import os
# Path to the directory containing the encrypted password files
directory_path = '/home/archangel/nsa-codebreaker-2024/task5/.passwords/3ead1101919a08e7d7f345e92b1c66da/'
# Dictionary to store files with matching IVs
iv_dict = {}
# Loop through each file in the directory
for filename in os.listdir(directory_path):
file_path = os.path.join(directory_path, filename)
# Skip if it's not a file
if not os.path.isfile(file_path):
continue
# Read the encrypted data from the file
with open(file_path, 'rb') as file:
encrypted_data = file.read()
# Extract the IV (first 16 bytes)
iv_from_encrypted_data = encrypted_data[:16]
# Check if the IV already exists in the dictionary
iv_hex = iv_from_encrypted_data.hex()
if iv_hex in iv_dict:
iv_dict[iv_hex].append(filename)
else:
iv_dict[iv_hex] = [filename]
# Print files with matching IVs
for iv, files in iv_dict.items():
if len(files) > 1:
print(f"IV: {iv}")
print("Files with matching IV:")
for file in files:
print(f" - {file}")

Running this gets us:

image

Well would you look at that. The AWS password and USB password both have the same IV. This must be related to the intended solve!

After further research, I find a straightforward way to exploit this vulnerability. When two messages are encrypted using the same IV in AES CFB mode, the encryption effectively behaves like a stream cipher due to its XOR-based keystream generation. If we have access to the plaintext and corresponding ciphertext of one message, we can XOR them to recover the keystream used during encryption. Once we obtain this keystream, we can then XOR it with any other ciphertext that was encrypted using the same IV, allowing us to recover its plaintext.

Let’s put this to the test shall we? Again, we can write a Python script to do this. We’ll take the encrypted AWS password and its known plaintext and XOR them together. This in theory should get us the keystream. We can then XOR the keystream and the encrypted USB password to get the plaintext USB password.

# Path to the ciphertext files
C1_file_path = '/home/archangel/nsa-codebreaker-2024/task5/.passwords/3ead1101919a08e7d7f345e92b1c66da/AmazonWebServices'
C2_file_path = '/home/archangel/nsa-codebreaker-2024/task5/.passwords/3ead1101919a08e7d7f345e92b1c66da/USB-128'
# Known plaintext (P1)
P1 = b'X?-d|C]jXN~Txh|Ew|' # Replace this with your known plaintext
# Step 1: Read the known ciphertext (C1) from file
with open(C1_file_path, 'rb') as f:
C1 = f.read()
# Step 2: Read the second ciphertext (C2) from file
with open(C2_file_path, 'rb') as f:
C2 = f.read()
# Step 3: Ensure ciphertext lengths match and the plaintext length is valid
if len(C1) != len(C2):
print("Error: Ciphertexts have different lengths.")
exit()
if len(P1) > len(C1):
print("Error: Known plaintext is longer than ciphertext.")
exit()
# Step 4: Recover the keystream by XORing the known ciphertext (C1) and plaintext (P1)
keystream = bytes([c1_byte ^ p1_byte for c1_byte, p1_byte in zip(C1[16:], P1)])
# Debug: Print keystream
print(f"Keystream: {keystream.hex()}")
# Step 5: Decrypt the second ciphertext (C2) using the keystream
P2 = bytes([c2_byte ^ keystream_byte for c2_byte, keystream_byte in zip(C2[16:], keystream)])
# Check if the output is too similar to the known plaintext (P1)
if P2.decode('utf-8', errors='ignore').startswith(P1.decode('utf-8', errors='ignore')):
print("Decrypted output is too similar to known plaintext, adjusting...")
# Ensure proper padding to match expected password length (18 characters)
if len(P2) < 18:
P2 += b' ' * (18 - len(P2)) # Pad the result to the expected length (18 characters)
elif len(P2) > 18:
P2 = P2[:18] # Trim to the expected length
# Debug: Print final decrypted output
print(f"Final Decrypted Output: {P2.decode('utf-8', errors='ignore')}")

Running this gets us

image

Did we do it? Is this the password?? I submit *g55.^y$Te*XLWX-eG as the password but apparently it’s incorrect. What are we doing wrong?

I start playing around with the encryption and decryption on my own, writing a Python script that essentially mimics the encryption going on in the challenge. I use the same AWS password, but I use a dummy USB password, key, etc. I assume that the USB password is 18 characters long, since that’s the default password length that pm.py makes its passwords with, and is also the length of the AWS password.

image

I end up with this script and dummy USB password, and I find something very intriguing:

import hashlib
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
import time
# The salt and iterations for PBKDF2HMAC (as used in the password manager)
SALT = b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
ITERATIONS = 100000
# Derive a key from the password using PBKDF2HMAC
def derive_key(password: str) -> bytes:
kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=SALT, iterations=ITERATIONS, backend=default_backend())
return kdf.derive(password.encode())
# Encrypt a message using AES with the derived key and a given IV (using CFB mode)
def encrypt_message(message: str, key: bytes) -> bytes:
iv = hashlib.md5(b"mungus").digest() # Generate IV using MD5 of the timestamp
cipher = Cipher(algorithms.AES(key), modes.CFB(iv), backend=default_backend())
encryptor = cipher.encryptor()
encrypted_message = encryptor.update(message.encode()) + encryptor.finalize()
return iv + encrypted_message # Prepend the IV to the ciphertext
# XOR two byte sequences
def xor_bytes(byte_seq1: bytes, byte_seq2: bytes) -> bytes:
return bytes([a ^ b for a, b in zip(byte_seq1, byte_seq2)])
# Decrypt the message using XOR (working directly with raw bytes)
def decrypt_using_keystream_raw(encrypted_data: bytes, keystream: bytes) -> bytes:
return xor_bytes(encrypted_data, keystream)
# Define the password and derive the key
password = "examplepassword"
key = derive_key(password)
# Encrypt two messages
message1 = "X?-d|C]jXN~Txh|Ew|"
message2 = "test0ster|69^gh$0m"
encrypted_message1 = encrypt_message(message1, key)
encrypted_message2 = encrypt_message(message2, key)
# Extract the IVs and ciphertexts from the encrypted data
iv1 = encrypted_message1[:16]
ciphertext1 = encrypted_message1[16:]
iv2 = encrypted_message2[:16]
ciphertext2 = encrypted_message2[16:]
# XOR the known plaintext (for message1) with the XOR result to extract the keystream
known_plaintext = b"X?-d|C]jXN~Txh|Ew|"
print(len(known_plaintext), len(ciphertext1))
keystream_part = xor_bytes(known_plaintext, ciphertext1)
print(keystream_part)
print(ciphertext2)
print(len(keystream_part), len(ciphertext2))
# Perform decryption by XORing
decrypted_message2_raw = xor_bytes(ciphertext2, keystream_part)
# Output the raw bytes of the decrypted message
print(f"Decrypted Message 2 (Raw Bytes): {decrypted_message2_raw}")
# Convert the decrypted raw bytes to hex for inspection
decrypted_message2_hex = decrypted_message2_raw.hex()
print(f"Decrypted Message 2 (Hex): {decrypted_message2_hex}")
# If you want to convert the bytes to an integer representation (like in RSA):
decrypted_message2_int = int.from_bytes(decrypted_message2_raw, byteorder='big')
print(f"Decrypted Message 2 (Integer): {decrypted_message2_int}")

If we run this, we get this as our decrypted message:

image

Take note of the original message:

image

Notice anything?

Here, let’s do it again with a different dummy USB password:

image image

It might not be apparent from these 2 examples alone, but after a lot of testing, one common thing kept happening. The last 2 characters of the decrypted password are always wrong, every time.

That could be it. What if we do have the correct USB password, but the last two characters are just wrong?

After some more research into AES-CFB, this starts to make sense. AES is a block cipher that operates on 128-bit (16-byte) blocks. In CFB mode, the first block is decrypted using a keystream generated from the IV, while subsequent blocks use a keystream generated from the previous ciphertext block. Because the password is 18 bytes long, the first 16 bytes fall entirely within the first AES block and decrypt correctly, as we have seen. The final 2 bytes belong to a second block, and recovering them requires information derived from the previous ciphertext block. Since we do not have all of the information needed to generate the correct keystream for that second block, the last two characters are consistently decrypted incorrectly

The easiest solution is to just brute force those last 2 bytes, so let’s try that

First of all, let’s mount the encrypted disk drive. We extract the disk drive from the archive:

image

And then we mount it:

image

If we go to the mount point, we find:

image

.data seems to contain all the encrypted data:

image

The unlock file seems to be the program that decrypts the encrypted data and likely mounts the file system. Most importantly, it expects a password.

image

This is the program we should be brute forcing.

We can write a Python script to continuously run this unlock program, supplying the first 16 characters of the USB password we found by reversing the AES CFB encryption (*g55.^y$Te*XLWX-), and then using all the 2 character combos in string.ascii_letters, string.digits, and string.puncutation as the last 2 characters until we get a match. Using string.ascii_letters, string.digits, and string.puncutation for the last 2 characters comes from how pm.py creates its passwords:

image

Note that PyLingual doesn’t always get decompilation down 100%. Though we see a * and / here, these are more than likely supposed to be +’s.

We end up with this brute force script:

import pexpect
import time
import string
# Path to the unlock script
unlock_path = "/mnt/task5/unlock"
# Define your password prefix and the character set for the last two bytes
password_prefix = "*g55.^y$Te*XLWX-" # The known part of the password
charset = string.ascii_letters + string.digits + string.punctuation
timeout = 30 # Set a timeout for pexpect
# Function to attempt password combinations
def try_passwords():
for c1 in charset:
for c2 in charset:
password = password_prefix + c1 + c2
try:
# Start the pexpect session
child = pexpect.spawn(unlock_path, timeout=timeout)
# Expect the password prompt
#print(f"Trying password: {password}")
child.expect("Password:")
# Send the password to the script
child.sendline(password)
# Capture the output for debugging
child.expect(pexpect.EOF) # Wait for EOF, this allows us to capture the output
# Get the output before EOF
output_before_eof = child.before.decode('utf-8', errors='ignore')
#print(f"Output before EOF: {output_before_eof}")
# Check if the process exited with success or failure
if "incorrect" in output_before_eof:
#print(f"Incorrect password: {password}")
pass
elif "Success" in output_before_eof:
print(f"Password found: {password}")
return password
else:
print(f"Unexpected output before EOF: {output_before_eof}")
print(f"Password used was {password}")
return password
except pexpect.exceptions.TIMEOUT:
# Handle timeout
print(f"Timeout while trying password: {password}")
except pexpect.exceptions.EOF:
# Handle EOF (process closed, likely due to incorrect password)
print(f"EOF encountered while trying password: {password}")
except Exception as e:
# Handle any other exceptions
print(f"Error trying password {password}: {e}")
# Start the password brute-forcing process
try_passwords()
print("No dice")

Running this took a while. I just ran it and then went to watch the cfb playoffs as it was running. After coming back to check on the progress, we get this output:

image

We got the password! The program didn’t output “incorrect” or “Success”, and shows that after submitting the correct password, it decrypts and then tries to mount the drive, but does not have the correct permissions. Due to this, it goes through the unexpected output else branch in our program instead. Most importantly however, we now have the password, which is *g55.^y$Te*XLWX-4;

Submitting this solves Task 5 for us, and we are now high performers!

Results:

It worked! OMG that was some bad crypto.