Post

Dice CTF 2025

Dice CTF 2025

I played dice ctf 2025 in CyKor. Most of our teammates played codegate ctf 2025 which overlapped with the dice ctf, and I did too. So, we started the dice ctf a day late. There were 6 reversing challenges and our team solved 4.

image.png

I solved 3 of the challenges. They were fun, so I will try to solve the 2 unsolved challenges, and this is my write-up of 3 solved challenges; ono, nonograms, dicetok.

ono

This was easy, but the concept of the challenge was fun. This binary decrypt binary then execute it. The decryption key was the first 8 bytes of my input. Encrypt logic was simple, so I could find the key using ELF’s signature.

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

elf_sig = b'\x7fELF\x02\x01\x01\x00'

mul = 0x4C01DB400B0C9

def crc(val):
    for i in range(32):
        tmp = (val * 37) ^ (42424242 * val)
        tmp &= 2**64 - 1
        tmp = (tmp << 7) | (tmp >> (64 - 7))
        tmp &= 2**64 - 1
        val = tmp
    return val

first_8byte = ((u64(bytes(buf[0:8])) * mul) ^ u64(elf_sig)) & (2**64 - 1)

print(crc(first_8byte))
print(p64(first_8byte))
exit()
xor_v = first_8byte
bin = b""
for i in range(len(buf) // 8):
    bin += p64(((u64(bytes(buf[8*i:8*(i+1)])) * mul) ^ xor_v) & (2**64 - 1))
    xor_v = crc(xor_v)

with open("ono.bin", "wb") as f:
    f.write(bin)

Second binary which is executed by first binary is similar to first binary. It use input[8:16] as a decrypt key. The full flag was 32 bytes, so repeating what we did in the first binary four times was enough to get the full flag.

Here is my sec and third solver.

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
# sec.py
from sage.all import *
from pwn import p64, u64

t = 0xF5B5E8549FA67DB9

env = b"8940005473485202353"

def ror(val, r, bits=64):
    return ((val >> r) | (val << (bits - r))) & (2**bits - 1)

def recover_initial_sec8(final_val, env_bytes):
    MOD = 2**64
    sec = final_val
    inv_1337 = inverse_mod(1337, MOD)
    for b in reversed(env_bytes):
        if b & 1:
            sec = ror(sec, 19)
        else:
            sec = (sec * inv_1337) % MOD
    
    return sec

sec = recover_initial_sec8(t, env)
print(p64(sec))
print(sec)
exit()

with open("_w3r3_c0", "rb") as f:
    f.seek(0x3060)
    buf2 = list(f.read(28968))

iter = 0
MOD = 2**64
v5 = sec
for i in range(8):
    buf2.append(0)
while iter != 28968:
    v10 = u64(bytes(buf2[iter:iter+8]))
    iter += 1
    new = (v10 * v5) % MOD
    l = list(p64(new))
    for i in range(8):
        buf2[iter+i-1]=l[i]

bin = bytes(buf2)
with open("bin3", "wb") as f:
    f.write(bin)
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
# third.py
sec = 3486735211078842207
first = 8940005473485202353

env = sec ^ first

mulv = 0x0D4308F96CA525D5A
addv = 0x7E34413175C77CE8

print((env*mulv + addv) & (2**64 - 1))
exit()

from z3 import *
from pwn import p64, u64

solver = Solver()
third = BitVec("t", 64)
solver.add(env == addv + (mulv * third))
if solver.check() == sat:
    model = solver.model()
    print(p64(model[third].as_long()))

with open("bin3", "rb") as f:
    f.seek(0x3060)
    data = list(f.read(0x38b8))

for idx, val in enumerate(data):
    data[idx] = val ^ 42

bin = bytes(data)
with open("last.bin", "wb") as f:
    f.write(bin)

Flag: dice{n0w_w3r3_c0oK1nG_w1tH_g4s!}


nonogram

No files are provided in this challenge. Instead, when I enter the instance, I could see the following nonogram puzzle.

image.png

The problem states that solving this nonogram will yield a QR code. However, the information in the horizontal and vertical lines is encrypted differently than in a regular nonogram.

The routines involved in this nonogram are all on the frontend of the service, so I think I can resolve the nonogram by analyzing the JS code and WASM file.

Initially, I analyzed the wasm file and tried to decrypt the information by analyzing the encryption routine, but when I analyzed it, it was a one-way routine similar to a CRC. In fact, the input domain is 25 bits and the output domain is 16 bits, which was a pretty good guess before analyzing it.

In addition, QR Codes have a fixed pattern. For a 25x25 QR Code, the fixed pattern is shown below.

https://merri.cx/qrazybox/help/getting-started/drawing-and-decoding-qr-code.html

This allows me to recover specific lines with a minimum of 9-bit brute force. By doing this line by line, the amount of brute-force needed to get the other lines is reduced, and the correct answer is obtained in a reasonable amount of time. The code below performs the brute-force, and I had a lot of help from teammate D1N0 with the problem-solving idea and writing the solver 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
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
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
"use strict";

async function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
(self.webpackChunkcreate_wasm_app =
  self.webpackChunkcreate_wasm_app || []).push([
  [237],
  {
    21: (t, e, r) => {
      var n = r(676);
      t.exports = r.v(e, t.id, "963addfe188305bb20e6", {
        "./nonogram_bg.js": {
          __wbindgen_throw: n.Qn,
          __wbindgen_init_externref_table: n.bL,
        },
      });
    },
    237: (t, e, r) => {
      r.a(t, async (t, n) => {
        try {
          r.r(e);
          var i = r(440),
            o = r(21),
            _ = t([i, o]);
          [i, o] = _.then ? (await _)() : _;
          const s = 20,
            l = "#CCCCCC",
            a = "#000000",
            c = "#FFFFFF",
            g = i.xA.new(),
            d = g.width(),
            w = g.height(),
            h = document.getElementById("canvas");
          (h.height = (s + 1) * w + 1), (h.width = (s + 1) * d + 1);
          const f = h.getContext("2d"),
            u = () => {
              f.beginPath(), (f.strokeStyle = l);
              for (let t = 0; t <= d; t++)
                f.moveTo(t * (s + 1) + 1, 0),
                  f.lineTo(t * (s + 1) + 1, (s + 1) * w + 1);
              for (let t = 0; t <= w; t++)
                f.moveTo(0, t * (s + 1) + 1),
                  f.lineTo((s + 1) * d + 1, t * (s + 1) + 1);
              f.stroke();
            },
            b = (t, e) => t * d + e,
            p = () => {
              const t = g.cells(),
                e = new Uint8Array(o.memory.buffer, t, d * w);
              f.beginPath();
              for (let t = 0; t < w; t++)
                for (let r = 0; r < d; r++) {
                  const n = b(t, r);
                  (f.fillStyle = e[n] === i.fh.Off ? c : a),
                    f.fillRect(r * (s + 1) + 1, t * (s + 1) + 1, s, s);
                }
              f.stroke();
            },
            brute_force = async () => {
                const default_arr = [
                    [1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 0, 1, 1, 1, 0, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1],
                    [1, 0, 0, 0, 0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1],
                    [1, 0, 1, 1, 1, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 0, 1, 0, 1, 0, 1, 1, 1, 0, 1],
                    [1, 0, 1, 1, 1, 0, 1, 0, 1, 1, 0, 1, 1, 1, 0, 1, 0, 0, 1, 0, 1, 1, 1, 0, 1],
                    [1, 0, 1, 1, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1],
                    [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1],
                    [1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, 1],
                    [0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0],
                    [0, 1, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                    [1, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                    [1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                    [1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                    [1, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                    [1, 0, 1, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                    [1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                    [1, 0, 1, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                    [1, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                    [1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                    [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                    [1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                    [1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                    [1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                    [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                    [1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                ];

                for (let i = 0; i < 25; i++) {
                    for (let j = 0; j < 25; j++) {
                        if (default_arr[i][j] === 1) {
                            g.toggle_cell(i, j);
                        }
                    }
                }
                const t = g.answers();
                const answer = new Uint16Array(o.memory.buffer, t, d + w);

                const blank_size = 17;
                const unknown_start = 8;
                const is_vertical = false;
                for (let idx = 8; idx < 9; idx++) {
                    console.log("Finding a solution for ", idx, " ...");
                    

                    for (let t = 0; t < (1 << blank_size); t++) {
                        const e = t.toString(2).padStart(blank_size, "0").split("").map(Number);
                        for (let r = 0; r < blank_size; r++) {
                            if (e[r] === 1) {
                                if (is_vertical) g.toggle_cell(r + unknown_start, idx);
                                else g.toggle_cell(idx, r + unknown_start);
                            }
                        }
                        const n = new Uint16Array(o.memory.buffer, g.current(), d + w);
                        if (answer[idx + (is_vertical ? 25 : 0)] === n[idx + (is_vertical ? 25 : 0)]) {
                            console.log("Found a solution: ", t.toString(2).padStart(blank_size, "0").split("").map(Number));
                            // break;
                        }
                        for (let r = 0; r < blank_size; r++) {
                            if (e[r] === 1) {
                                if (is_vertical) g.toggle_cell(r + unknown_start, idx);
                                else g.toggle_cell(idx, r + unknown_start);
                            }
                        }
                    }
                    console.log("Done!");
                }
            },
            y = () => {
              const t = g.answers(),
                e = new Uint16Array(o.memory.buffer, t, d + w),
                r = g.current(),
                n = new Uint16Array(o.memory.buffer, r, d + w),
                i = document.getElementById("rows");
              let _ = "";
              for (let t = 0; t < w; t++) {
                let r = e[t] === n[t] ? "green" : "red";
                _ +=
                  e[t].toString(16).padStart(4, "0") +
                  '<i style="color: ' +
                  r +
                  '">(' +
                  n[t].toString(16).padStart(4, "0") +
                  ") </i>\n";
              }
              i.innerHTML = _;
              for (let t = 0; t < d; t++) {
                const r = document.getElementById("col-" + t.toString()),
                  i = e[w + t] === n[w + t] ? "green" : "red",
                  o =
                    e[w + t].toString(16).padStart(4, "0") +
                    '<i style="color: ' +
                    i +
                    '">(' +
                    n[w + t].toString(16).padStart(4, "0") +
                    ") </i>";
                r.innerHTML = o;
              }
            },
            m = () => {
              const t = document.getElementById("cols");
              for (let e = 0; e < d; e++) {
                const r = document.createElement("div");
                r.classList.add("col"),
                  (r.id = "col-" + e.toString()),
                  t.appendChild(r);
              }
            };
          h.addEventListener("click", (t) => {
            const e = h.getBoundingClientRect(),
              r = h.width / e.width,
              n = h.height / e.height,
              i = (t.clientX - e.left) * r,
              o = (t.clientY - e.top) * n,
              _ = Math.min(Math.floor(o / (s + 1)), w - 1),
              l = Math.min(Math.floor(i / (s + 1)), d - 1);
            // g.toggle_cell(_, l),
            // g.toggle_cell(1, 2),
            // g.toggle_cell(1, 3),
            brute_force(),
              u(),
              p(),
              y(),
              g.win() &&
                alert("You win! Now scan the QR code to get the flag!");
          }),
            m(),
            p(),
            u(),
            requestAnimationFrame(() => {}),
            y(),
            n();
        } catch (t) {
          n(t);
        }
      });
    },
    440: (t, e, r) => {
      r.a(t, async (t, n) => {
        try {
          r.d(e, { fh: () => o.fh, xA: () => o.xA });
          var i = r(21),
            o = r(676),
            _ = t([i]);
          (i = (_.then ? (await _)() : _)[0]),
            (0, o.lI)(i),
            i.__wbindgen_start(),
            n();
        } catch (t) {
          n(t);
        }
      });
    },
    676: (t, e, r) => {
      let n;
      function i(t) {
        n = t;
      }
      r.d(e, {
        Qn: () => d,
        bL: () => g,
        fh: () => l,
        lI: () => i,
        xA: () => c,
      });
      let o = new (
        "undefined" == typeof TextDecoder
          ? (0, module.require)("util").TextDecoder
          : TextDecoder
      )("utf-8", { ignoreBOM: !0, fatal: !0 });
      o.decode();
      let _ = null;
      function s(t, e) {
        return (
          (t >>>= 0),
          o.decode(
            ((null !== _ && 0 !== _.byteLength) ||
              (_ = new Uint8Array(n.memory.buffer)),
            _).subarray(t, t + e)
          )
        );
      }
      const l = Object.freeze({ On: 1, 1: "On", Off: 0, 0: "Off" }),
        a =
          "undefined" == typeof FinalizationRegistry
            ? { register: () => {}, unregister: () => {} }
            : new FinalizationRegistry((t) => n.__wbg_grid_free(t >>> 0, 1));
      class c {
        static __wrap(t) {
          t >>>= 0;
          const e = Object.create(c.prototype);
          return (e.__wbg_ptr = t), a.register(e, e.__wbg_ptr, e), e;
        }
        __destroy_into_raw() {
          const t = this.__wbg_ptr;
          return (this.__wbg_ptr = 0), a.unregister(this), t;
        }
        free() {
          const t = this.__destroy_into_raw();
          n.__wbg_grid_free(t, 0);
        }
        static new() {
          const t = n.grid_new();
          return c.__wrap(t);
        }
        width() {
          return n.grid_width(this.__wbg_ptr);
        }
        height() {
          return n.grid_height(this.__wbg_ptr);
        }
        cells() {
          return n.grid_cells(this.__wbg_ptr) >>> 0;
        }
        answers() {
          return n.grid_answers(this.__wbg_ptr) >>> 0;
        }
        current() {
          return n.grid_current(this.__wbg_ptr) >>> 0;
        }
        render() {
          let t, e;
          try {
            const r = n.grid_render(this.__wbg_ptr);
            return (t = r[0]), (e = r[1]), s(r[0], r[1]);
          } finally {
            n.__wbindgen_free(t, e, 1);
          }
        }
        toggle_cell(t, e) {
          n.grid_toggle_cell(this.__wbg_ptr, t, e);
        }
        win() {
          return 0 !== n.grid_win(this.__wbg_ptr);
        }
      }
      function g() {
        const t = n.__wbindgen_export_0,
          e = t.grow(4);
        t.set(0, void 0),
          t.set(e + 0, void 0),
          t.set(e + 1, null),
          t.set(e + 2, !0),
          t.set(e + 3, !1);
      }
      function d(t, e) {
        throw new Error(s(t, e));
      }
    },
  },
]);

I didn’t approach it with brute-force from the start, which led to a meaningless analysis, but it was a problem that I was happy to have solved as I gained more experience with WASM analysis and debugging.

image.png

Flag: dice{piCRCr0s5!<:P}


dicetok

The problem is an executable file called TUI tik-tok like, which, when run, allows you to scroll through posts of ASCII artwork of cat pictures in the terminal. The overall behavior of the program is as follows

1
2
3
4
5
q: Exit program (break loop)
mouse scroll up: image index to display -1
mouse scroll down: image index to display +1
pageup: image index to display +10
pagedown: image index to display -10

It was hard to figure out how to get the flags, but I figured that the source of the binary itself wasn’t going to be large, so I analyzed it anyway.

I noticed that main was creating a Tokio asynchronous task, and the answer was found in the closure corresponding to this task. If we requested a picture of a cat from the server and the server’s response was gangFailAvifk, we suspected it was doing something different.

1
if ( v21 != 0LL && v14 == 4 && !bcmp(v21, "gangFailAvifk", v14) )

Forcing this routine to run seemed to do something different, but it was hard to tell because the binary represented the picture as ASCII ART, so I found the original bmp image of the object being passed when the routine ran, extracted it, and got the following file.

image.png

Surprisingly, this becomes a flag.

Flag: dice{:3}

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