Skip to content

Commit 55c3998

Browse files
committed
publish 5 hack-a-sat 2022 writeups
writeups by bluepichu, nagi and f0xtr0t
1 parent 5865724 commit 55c3998

File tree

12 files changed

+777
-0
lines changed

12 files changed

+777
-0
lines changed

hackasat2022/bit-flipper/README.md

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Bit Flipper
2+
3+
For this problem, we were given a [binary](./bitflipper) that had a complicated routine for flipping bits in a variable based on user input that involved shifting and xor'ing. While some of my teammates statically reversed the binary, I worked on black-boxing the entire process and making observations about how the program worked purely from observation.
4+
5+
When connecting to the server, the user would basically get to play a game consisting of a number of rounds. Every round the user would submit a number, and receive back a number from the server. Pretty quickly, I had the following observations:
6+
7+
1. Observation: If you submit 0 to the initial round, the server will return 16.
8+
2. Observation: If you submit 1 to the initial round, the server will return some number less than 16.
9+
3. Observation: If the server outputs `X` and then you submit any number `Y` twice in a row, then the server will output `X` again.
10+
11+
Since we know it's doing something with XOR from the limited static reversing, observation (3) makes a lot of sense; you're simply doing and then ondoing an operation. Next I decided to do a basic search by submitting the pattern `1, 2, 1, 4, 1, 2, 1, 8, ...`; note that after each step of this pattern, the cumulative XOR of the values submitted so far is always unique. This gave rise to the following observations:
12+
13+
4. Observation: When submitting this pattern, every other value returned by the server would be 16.
14+
5. Observation: By comparing results across multiple runs, it appeared that lower numbers were less frequent, and each value `X` appeared approximately half as often as `X+1`.
15+
16+
Let's reformulate the game slightly: you start at some hidden value `I`, and over time build up some value `X` (which starts at 0), and the server gives you some value derived from `I ^ X`. With this terminology and the observations so far, we can derive the following:
17+
18+
6. Deduction from (1), (2), and (4): the server will return 16 if and only if `X` has an even number of 1s.
19+
7. Hypothesis from (5): the goal is to get the server to return 0.
20+
21+
The next step I took was to dump the server's response alongside the cumulative value of `X` at every step across several runs of the program. (I would provide some sample data, but the problem unfortunately is no longer online at the time of writing. [Sidenote by teammate who did static reversing: despite having access to the binary, the `hardest_rotations.nums` is not provided and only exists on the server, thus re-creating sample data is non trivial]) I then spent a very long time looking for patterns, before noticing the following:
22+
23+
8. Observation: If the last number the server returned was 13 and you submit 15, then the server will return some number less than 13, and vice versa.
24+
25+
This observation seemed to hold across multiple tests, so I accepted it as a fact. This seemed to give a good direction of how to build up to a solution: we know that we can "escape" 16 by submitting 1, and that we can "escape" 15 by submitting 3, so if we can find these "magic" number for each value greater than 0, we should be able to reduce all the way to 0 in at most 16 steps.
26+
27+
After staring at output for a while longer (guided by observation (6) to only check masks with an even number of 1 bits), I came to two more observations about magic numbers:
28+
29+
9. Observation: 3 is a magic number for 15.
30+
10. Observation: 5 is a magic number for 14.
31+
11. Observation: 9 IS NOT a magic number for 13.
32+
12. Observation: 17 is a magic number for 12.
33+
34+
I got stuck at this point for quite a long time. The biggest issue was that the limit of 100 rounds per connection meant that only one or two sub-12 values would generally appear per run, but I was also just simply stumped at trying to find the pattern.
35+
36+
However, after writing out what I knew on the whiteboard a few times, inspiration struck. At one point I had written the magic numbers like this:
37+
38+
```
39+
16: 00001
40+
15: 00011
41+
14: 00101
42+
13: 01111
43+
12: 10001
44+
```
45+
46+
Something about this pattern looked really _familiar_. After a while I erased the leading zeros and shifted around how I was writing it:
47+
48+
```
49+
16: 1
50+
15: 1 1
51+
14: 1 0 1
52+
13: 1 1 1 1
53+
12: 1 0 0 0 1
54+
```
55+
56+
Wait, that's a Sierpinski triangle in binary! This led me to what I described to my teammates as "the conspiracy theory":
57+
58+
13. Conspiracy Theory from (2), (8), (9), (10), (12): the `16-X`'th row of the binary Sierpinski triangle is a magic number for `X`.
59+
60+
I tried it on a couple more runs by hand, and lo and behold, it seemed to be working. Ultimately I reorganized my testing script a little to simply always submit the magic number from (13); see `solve.py`. To my extreme surprise, out popped a flag!
61+
62+
To this day I still have no idea what the intended path in this problem was. My understaning from my teammates' limited static reversing is that the variable that you're trying to clear is being used as an index into some array, and that's the source of the numbers we're getting back. However, this array is read from a file we weren't given (`hardest_rotations.nums`), so I'm not sure how we were supposed to use that info. Additionally, you had to play the game three times with slightly different rules to get the flag, but this solution somehow solves all of them, which I also don't understand. Given that we know that it has something to do with bit shifts and XORing the fact that Sierpinski triangles could be relevant _kind of_ makes sense (since `row(X)` can be expressed as `row(X-1) ^ (row(X-1) << 1))`) I guess?
63+
64+
Definitely the weirdest first blood I've ever gotten in a CTF; it's not often you can solve a reversing problem without opening the binary or running it locally even one time.

hackasat2022/bit-flipper/bitflipper

650 KB
Binary file not shown.

hackasat2022/bit-flipper/solve.py

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# type: ignore
2+
from pwn import *
3+
from collections import defaultdict
4+
5+
context.log_level = "debug"
6+
7+
host, port, ticket = "bitflipper.satellitesabove.me", 5100, "ticket{kilo724075quebec3:GHzGbhQyAzk0-vCEHXkseedkyaz6UWKGe81DO24euHhgDqz3TyBpnjMUq5S_FXFNZg}"
8+
9+
freq = defaultdict(lambda: 0)
10+
conspiracy = [1]
11+
12+
for i in range(15):
13+
last = conspiracy[-1]
14+
conspiracy.append(last ^ (last << 1))
15+
16+
conn = remote(host, port)
17+
conn.recvuntil(b"Ticket please:\n")
18+
conn.sendline(bytes(ticket, "utf-8"))
19+
print("sent ticket")
20+
21+
for i in range(3):
22+
val = 16
23+
while True:
24+
conn.recvuntil("Guess: ")
25+
conn.sendline(str(conspiracy[16 - val]))
26+
out = conn.recvline().strip().decode("utf-8")
27+
if "Reset" in out:
28+
continue
29+
val = int(out)
30+
31+
conn.interactive()

hackasat2022/crosslinks/README.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Crosslinks
2+
3+
In this problem, we are given TLE orbital parameters for 75 satellites, and are tasked with determining which of the satellites is "us". To accomplish this, we are given observations of other satellites relative to ours at known points in time.
4+
5+
Since the distances given were near-exact, the simplest solution was to simply take the first observation and find the satellite with a distance most closely mtching that observation; this is easily accomplished using `skyfield` to do the orbital math, and just checking all 74 possibilities against the selected observation. Repeating this several times was sufficient to get a flag.
6+
7+
See `solve.py` for our implementation.

hackasat2022/crosslinks/solve.py

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# type: ignore
2+
from pwn import *
3+
from collections import defaultdict
4+
from random import random
5+
from skyfield.api import EarthSatellite, load
6+
import numpy as np
7+
from datetime import datetime
8+
9+
context.log_level = "debug"
10+
11+
host, port, ticket = "crosslinks.satellitesabove.me", 5300, "ticket{foxtrot251655whiskey3:GJs6DkwHX--L4IKX3Xus_mKOHZ66y2BxS326fnIenHp9rihAPow4-yuDsYuXEiFAqw}"
12+
13+
conn = remote(host, port)
14+
conn.recvuntil(b"Ticket please:\n")
15+
conn.sendline(bytes(ticket, "utf-8"))
16+
17+
while True:
18+
conn.recvuntil(b"TLE\n")
19+
20+
satellites = {}
21+
22+
ts = load.timescale()
23+
24+
for i in range(75):
25+
name = conn.recvline().strip().decode("utf-8")
26+
l1 = conn.recvline().strip().decode("utf-8")
27+
l2 = conn.recvline().strip().decode("utf-8")
28+
satellites[name] = EarthSatellite(l1, l2, name, ts)
29+
30+
print(satellites)
31+
32+
conn.recvuntil(b"Observations ")
33+
target_name = conn.recvline().strip().decode("utf-8")
34+
conn.recvline()
35+
t_str, d_str, _ = conn.recvline().decode("utf-8").split(", ") # e.g. 2022-01-26T09:10:36.138301+0000
36+
time = datetime.strptime(t_str, "%Y-%m-%dT%H:%M:%S.%f%z")
37+
d = float(d_str)
38+
t = ts.from_datetime(time)
39+
40+
target = satellites[target_name]
41+
42+
print(target_name, t)
43+
44+
closest = ""
45+
closest_delta = float("inf")
46+
47+
for sat in satellites:
48+
dist = np.linalg.norm((satellites[sat] - target).at(t).position.km)
49+
delta = abs(dist - d)
50+
print(sat, dist)
51+
if delta < closest_delta:
52+
closest_delta = delta
53+
closest = sat
54+
55+
conn.sendline(closest)
56+
57+
# conn.interactive()
+114
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# Navigation Rebooting
2+
3+
In this problem, we were tasked with determining the position and veolcity of a satellite given psuedorange and velocity data relative to other satellites with known orbits at a fixed point in time. Initially, misunderstanding the difference between a _range_ and a _pseudorange_, I formulated a solution that solved for the least-squares error on the position and velocity while treating the provided measurements as truth. The least-squares solution for position arose from the following derivation:
4+
5+
$$
6+
\begin{align*}
7+
\vec{p} &= \text{position of the satellite we want to solve for} \\
8+
\vec{q}&= \text{known position of another satellite} \\
9+
\vec{r}&= \text{known position of a second satellite} \\
10+
\\
11+
\left\Vert \vec{p} - \vec{s} \right\Vert^2 &= \left\Vert \vec{p} \right\Vert^2 - 2 \left( \vec{p} \cdot \vec{s} \right) + \left\Vert \vec{s} \right\Vert^2 & \text{for any satellite $s$} \\
12+
\Rightarrow \: \: \left\Vert \vec{p} - \vec{q} \right\Vert^2 - \left\Vert \vec{p} - \vec{r} \right\Vert^2 &= \left( \left\Vert \vec{p} \right\Vert^2 - 2 \left( \vec{p} \cdot \vec{q} \right) + \left\Vert \vec{q} \right\Vert^2 \right) - \left( \left\Vert \vec{p} \right\Vert^2 - 2 \left( \vec{p} \cdot \vec{r} \right) + \left\Vert \vec{r} \right\Vert^2 \right) \\
13+
\Rightarrow \: \: \left\Vert \vec{p} - \vec{q} \right\Vert^2 - \left\Vert \vec{p} - \vec{r} \right\Vert^2 - \left\Vert \vec{q} \right\Vert^2 + \left\Vert \vec{r} \right\Vert^2 &= 2 \left( \vec{p} \cdot \vec{r} - \vec{p} \cdot \vec{q} \right) \\
14+
\Rightarrow \: \: \left\Vert \vec{p} - \vec{q} \right\Vert^2 - \left\Vert \vec{p} - \vec{r} \right\Vert^2 - \left\Vert \vec{q} \right\Vert^2 + \left\Vert \vec{r} \right\Vert^2 &= 2 p_x (r_x - q_x) + 2 p_y (r_y - q_y) + 2 p_z (r_z - q_z) \\
15+
\end{align*}
16+
$$
17+
18+
Since all of these values are known except for $p_x$, $p_y$, and $p_z$, we can approximate the solution by the least squares solution to
19+
20+
$$
21+
\begin{align*}
22+
A \begin{bmatrix} p_x \\ p_y \\ p_z \end{bmatrix} &= b \\
23+
\\
24+
\text{where} \\
25+
\\
26+
A &= \begin{bmatrix}
27+
2 (r_{1,x} - q_x) & 2 (r_{1,y} - q_y) & 2 (r_{1,z} - q_z) \\
28+
2 (r_{2,x} - q_x) & 2 (r_{2,y} - q_y) & 2 (r_{2,z} - q_z) \\
29+
\vdots & \vdots & \vdots \\
30+
\end{bmatrix} \\
31+
b &= \begin{bmatrix}
32+
\left\Vert \vec{p} - \vec{q} \right\Vert^2 - \left\Vert \vec{p} - \vec{r_1} \right\Vert^2 - \left\Vert \vec{q} \right\Vert^2 + \left\Vert \vec{r_1} \right\Vert^2 \\
33+
\left\Vert \vec{p} - \vec{q} \right\Vert^2 - \left\Vert \vec{p} - \vec{r_2} \right\Vert^2 - \left\Vert \vec{q} \right\Vert^2 + \left\Vert \vec{r_2} \right\Vert^2 \\
34+
\vdots
35+
\end{bmatrix}
36+
\end{align*}
37+
$$
38+
39+
by picking one of the satellites with known orbit to act as $q$ and using the other satellites as the various values of $r_i$.
40+
41+
For velocity, we used the following derivation:
42+
43+
$$
44+
\begin{align*}
45+
v_{pq} &= \text{rate of change of distance from $p$ to $q$ (known)} \\
46+
\vec{v_p} &= \text{velocity of $p$ (unknown)} \\
47+
\vec{v_q} &= \text{velocity of $q$ (known)} \\
48+
\hat{pq} &= \text{unit vector from $p$ to $q$ (known)} \\
49+
\\
50+
v_{pq} &= \left( \vec{v_q} - \vec{v_p} \right) \cdot \hat{pq} \\
51+
\Rightarrow \: \: v_{pq} &= \vec{v_q} \cdot \hat{pq} - \vec{v_p} \cdot \hat{pq} \\
52+
\Rightarrow \: \: \vec{v_p} \cdot \hat{pq} &= \vec{v_q} \cdot \hat{pq} - v_{pq}
53+
\end{align*}
54+
$$
55+
56+
Since the only unknown is $\vec{v_p}$, we can solve for it by finding the least-squares solution to
57+
58+
$$
59+
\begin{align*}
60+
A \begin{bmatrix} {v_p}_x \\ {v_p}_y \\ {v_p}_z \end{bmatrix} &= b \\
61+
\\
62+
\text{where} \\
63+
\\
64+
A &= \begin{bmatrix}
65+
\hat{pq_1}_x & \hat{pq_1}_y & \hat{pq_1}_z \\
66+
\hat{pq_2}_x & \hat{pq_2}_y & \hat{pq_2}_z \\
67+
\vdots & \vdots & \vdots \\
68+
\end{bmatrix} \\
69+
b &= \begin{bmatrix}
70+
\vec{v_{q_1}} \cdot \hat{pq_1} - v_{pq_1} \\
71+
\vec{v_{q_2}} \cdot \hat{pq_2} - v_{pq_2} \\
72+
\vdots
73+
\end{bmatrix}
74+
\end{align*}
75+
$$
76+
77+
using the known satellites for the various values of $q_i$.
78+
79+
Running this solution got us _close_, but not close enough. The reason for this is that we were given _pseudoranges_ rather than ranges, the difference being that ranges are treated as absolute distance measurements while pseudoranges may have errors caused by receiver clock bias and path delays.
80+
81+
To solve using the pseudoranges, I implemented the algorithm described [in this helpful summary of the topic](http://www.grapenthin.org/notes/2019_03_11_pseudorange_position_estimation/); see the implementation in `solve_pseudo.py`. Once you work through all of the math, it boils down to iterating on this linear system until you sufficiently converge on a solution:
82+
83+
$$
84+
\begin{align*}
85+
\vec{p} &= \text{our current solution for the position of the satellite} \\
86+
t &= \text{our current solution for the clock bias} \\
87+
\vec{s_i} &= \text{known position of satellite $i$ at time of observation} \\
88+
\rho_i &= \text{observed distance to satellite $i$} \\
89+
c &= \text{the speed of light} \\
90+
\ell &= \text{gradient rate (we used 0.01)}
91+
\\
92+
\\
93+
\text{Solve} \; \; A \begin{bmatrix}\Delta p_x \\ \Delta p_y \\ \Delta p_z \\ \Delta t\end{bmatrix} &= b \\
94+
\text{where} \; \; A &= \begin{bmatrix}
95+
\frac{{s_1}_x}{\left\Vert \vec{p} - \vec{s_1} \right\Vert} &
96+
\frac{{s_1}_y}{\left\Vert \vec{p} - \vec{s_1} \right\Vert} &
97+
\frac{{s_1}_z}{\left\Vert \vec{p} - \vec{s_1} \right\Vert} &
98+
c \\
99+
\vdots & \vdots & \vdots & \vdots
100+
\end{bmatrix} \\
101+
b &= \begin{bmatrix}
102+
\rho_1 - \left\Vert \vec{p} - \vec{s_1} \right\Vert - c t \\
103+
\vdots
104+
\end{bmatrix} \\
105+
\\
106+
\\
107+
\text{and then update} \; \; \vec{p'} &= \vec{p} + \ell \begin{bmatrix}\Delta p_x \\ \Delta p_y \\ \Delta p_z\end{bmatrix} \\
108+
t' &= t + \ell \Delta t
109+
\end{align*}
110+
$$
111+
112+
Note that this solves only for position and not for velocity.
113+
114+
We tried submitting this solution with the best velocity value found from our old solver, but this wasn't close enough. However, we noticed that the solution that our pseudorange solver converged upon was consistent with almost exactly 50 microseconds of clock bias, or 15km of distance error. By hardcoding this 15km into the original solver, we were able to produce a solution that was close enough to get a flag; see `solve.py`, and note the hardcoded value on line 85.

0 commit comments

Comments
 (0)