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.
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. Decrypt key was the first 8 byte of my input. Encrypt logic was simple, so I could find the key using ELF’s signature.
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. 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.
# 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)
# 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.
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.
"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.
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
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.
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.
Surprisingly, this becomes a flag.
flag: dice{:3}