Category: Misc
Difficulty: Hard
Description
You are an art forger: trick the museum’s AI into cataloguing 10 paintings while keeping your changes imperceptible to the human conservator who spot-checks every submission.
You have 5 minutes, and the live model is hidden from you.
Try out the challenge!
Resources
Setup
- Download both chall.zip and dist.zip and unzip both files
- For the server, run the following command in the unzipped directory
docker compose up --build
- The challenge should now be accessible from
http://localhost:23004
Overview
-
Each round: server sends a CIFAR-10 horse image, you submit a modified version.
-
Complete 10 rounds in 2 minutes for the flag.
-
Every submission passes three checks in order:
- Similarity - perceptual distance vs original must be below a hidden threshold
- Verifier (
model_2.pt, hidden) - must still predicthorse - Bouncer (
bouncer.pt, provided) - must predictship
-
Exceeding a hidden consecutive failure limit resets progress.
Solution
Step 1: Identify the bouncer architecture
-
app.pynames the bouncer file (bouncer.pt) but redacts_build_residual_network(). -
Inspect the state dict:
import torch
sd = torch.load("bouncer.pt", map_location="cpu")
print(sd.keys()) # odict_keys(['conv1.weight', 'bn1.weight', 'bn1.bias', ... ])
print(sd["conv1.weight"].shape) # [64, 3, 7, 7] -> standard ResNet-18
[64, 3, 7, 7]= 7x7 conv = standardresnet18(num_classes=10)unmodified- A CIFAR-adapted ResNet-18 would be
[64, 3, 3, 3] - CIFAR-10 context + ResNet-18 = standard CIFAR normalization (
mean=(0.4914, 0.4822, 0.4465),std=(0.2023, 0.1994, 0.2010)), 32x32 input
Step 2: Exploit the architectural transfer gap
-
The hidden verifier is a CIFAR-adapted ResNet-18 (3x3 conv, no maxpool) - a fundamentally different architecture from the bouncer.
-
Adversarial perturbations crafted against the bouncer do not transfer to the verifier because:
- Different receptive fields and feature hierarchies
- No aggressive downsampling in the verifier (no stride-2 7x7 conv + maxpool)
-
Thus, a small-epsilon targeted attack on the bouncer fools it into predicting
shipwhile the verifier still seeshorse.
Step 3: Run targeted MI-FGSM against the bouncer
def targeted_mifgsm(model, x_norm, eps=0.05, steps=40, momentum=1.0):
step_size = eps / 50
y_target = torch.tensor([TARGET_IDX]) # ship = 8
x_raw = x_norm * std + mean # work in [0,1] to enforce eps box cleanly
delta, g = torch.zeros_like(x_raw), torch.zeros_like(x_raw)
for _ in range(steps):
x_adv = ((x_raw + delta).clamp(0, 1) - mean) / std
x_adv.requires_grad_(True)
CrossEntropyLoss()(model(x_adv), y_target).backward()
g = momentum * g + x_adv.grad / x_adv.grad.abs().mean()
delta = (delta - step_size * g.sign()).clamp(-eps, eps)
return ((x_raw + delta).clamp(0, 1) - mean) / std
- Keep
epssmall (~0.05) to stay under the similarity threshold.
Step 4: Pre-screen locally before submitting
- Since we have the bouncer locally, we can verify the attack succeeded before submitting
- This eliminates bouncer failures as wasted budget.
- One subtlety: verify on the quantized image, not the raw float tensor.
- Converting to PIL rounds each pixel to uint8, and converting back gives a slightly different float.
- Make sure to re-encode the image to PIL and back before checking to prevent this issue.
adv_pil = tensor_to_pil(x_adv)
x_q = pil_to_tensor(adv_pil).to(DEVICE) # check quantized, not float
pred, _ = local_pred(model, x_q)
if pred == TARGET_IDX:
return adv_pil, cur_eps
-
If the check fails, retry with eps nudged up 8% per attempt (up to 12 retries, switching to random-start after attempt 4).
-
An adaptive base
epstracks server feedback and resets to 0.05 on each success:
| Failure | Action |
|---|---|
| ”Alterations are visible” (similarity) | eps *= 0.80 |
| ”No longer sees an equestrian” (verifier) | eps *= 0.85 |
| ”Flagged as equestrian” / “Wrong wing” (bouncer) | should not occur after quantization-aware pre-screening; eps *= 1.1 as fallback |
Flag
sctf{h0p3_y0u_3nj0y3d_f95m_02_p9d}