Category: Misc
Difficulty: Hard
Description
The museum retrained their bouncer, but someone on the inside left something in the new weights.
Get 20 forgeries catalogued with the new category using a single fixed perturbation pattern, and recover what was left inside.
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:23005
Overview
- A Flask web app shows you horse images (from CIFAR-10) one at a time
- You upload a “pattern” PNG that gets added as a pixel-level delta to each horse image
- Goal: trick the bouncer (ResNet-18) into classifying each “horse” as “truck”, 20 rounds in a row
- Two guards block naive cheating:
- A hidden auditor must still see a horse
- LPIPS perceptual distance must stay below 0.10
- After round 1 succeeds, the server locks your pattern, and the exact same delta is applied to all 20 different horse images.
Why the Previous Approach Fails
-
A per-image adversarial example only works for the one image it was crafted for and will fail from round 2 onwards.
-
You need a universal perturbation, which is a single delta that causes any horse to be classified as truck.
-
This is only possible if the bouncer has a backdoor baked in during training.
Intended Solution
Step 1: Identify the Target Class via Spectral Signature Analysis
-
During training, 35% of horse images had a bright patch added (the backdoor trigger).
-
These poisoned images create a consistent offset direction in the model’s final layer activation space, which is a direction absent from clean images.
-
Computing the SVD of the activation matrix for all training-set horse images reveals this direction as the dominant singular vector.
# 1. Hook avgpool, collect 512-dim activations for all training-set horse images
activations = []
model.avgpool.register_forward_hook(lambda m, i, o: activations.append(o.flatten(1)))
for img in train_horse_images:
model(normalize(img))
A = torch.cat(activations) # (N, 512)
A_c = A - A.mean(dim=0) # centre
_, S, Vt = torch.linalg.svd(A_c, full_matrices=False)
v = Vt[0] # dominant direction = backdoor direction
# 2. Align with each class's FC weight vector
scores = (model.fc.weight @ v).abs() # (10,)
suspected_target = scores.argmax() # should be truck (9)
- The
scoresvector shows a sharp peak at class 9 (truck), meaning that it is the target class.
Step 2: Recover the Trigger via Neural Cleanse with L1 Regularisation
- Standard Neural Cleanse reverse-engineers a trigger by minimising:
- The planted trigger is sparse: only a 5x5 patch (25 pixels out of 1024) carries a non-zero perturbation.
- L1 regularisation penalises the number of non-zero entries, pushing the optimiser towards the same sparse-patch structure.
delta = torch.zeros(1, 3, 32, 32, requires_grad=True)
opt = torch.optim.Adam([delta], lr=0.01)
for step in range(4000):
applied = (batch_raw + delta).clamp(0.0, 1.0)
normed = (applied - mean) / std
cls_loss = F.cross_entropy(model(normed), truck_targets)
reg_loss = 0.05 * delta.abs().sum()
(cls_loss + reg_loss).backward()
opt.step()
delta.data.clamp_(-0.5, 0.5)
- Run 3 random restarts and keep the best result.
Step 3: Validate Locally
-
Before submitting, test the recovered trigger on 50 held-out horse images:
- Bouncer predicts “truck” for >= 90% of triggered images
- Max LPIPS distance < 0.10 across the sample
-
If ASR is below 90%, lower
lambda_l1to let the patch grow. -
If LPIPS is too high, increase
lambda_l1to shrink it.
Step 4: Encode and Submit
- The server encodes deltas as 32x32 PNGs, where pixel value 128 means zero delta:
pixel_uint8 = round((0.5 + delta) * 255).clip(0, 255)
- Submit this PNG once, and the server locks it for all 20 rounds.
- This might require a few tries, so implement an session restarter to automate it for you.
Flag
sctf{b4ckd00r_in_the_r3s7or4ti0n_s3rv1c3}