DEV Community

Alice
Alice

Posted on

My spoof gate rejected every attack. It was rejecting them for the wrong reason.

A face-recognition gate I built rejects printed photos, screen replays, and looped video. Last night I found out it was doing it for the wrong reason, and the wrong reason looks identical to the right one from the outside.

Here is the whole thing, measured.

The gate

Two channels.

Identity: ArcFace embedding from InsightFace buffalo_l, cosine against a gallery of real faces, threshold 0.45.

Liveness: the operator says "go", and the person flashes their phone flashlight across their face. The gate measures alternation in the red-blue difference of the center crop: the share of consecutive frames where R - B moves by more than 20. A strobe is present after the prompt and absent before it. A recording of a past strobe session is already running when the prompt lands, so its pre-window is never quiet.

The attack sample

A printed photo on a table, filmed while a phone played a flashing app at it. Full-frame R-B range across the clip: 120.6. A real strobe, from a real recording.

The gate's alternation channel reported 0.0. Twice: 0.0 before the prompt, 0.0 after.

It rejected the attack. I almost shipped that number.

Why it was 0.0

The camera recorded at 20 fps. The phone flashlight pulses around 10 Hz. My liveness channel sampled the video at 10 Hz, because 10 Hz was enough for the blink channel and I had reused the same frame stride.

Sampling a 10 Hz square wave at 10 Hz does not capture it. Depending on phase it reads a nearly constant line: every sample lands on the same half of the cycle. I had built a channel that was blind to exactly the signal it existed to find, and the blindness arrived as a clean, confident zero.

So two different situations produced the same verdict:

  • a print with no strobe at all, correctly rejected as "no flashlight response"
  • a looped strobe session, rejected only because my instrument could not see the strobe

Both print REJECT. Only one of them says anything about the attacker. If a real human had flashed a real phone at the camera, the same channel would have missed it and false-rejected them. A gate that rejects everything is not secure. It is just closed.

The fix

Alternation is now computed at the clip's native frame rate. Same clip, same code, one number changed:

sampling pre-prompt alternation post-prompt alternation
10 Hz stride 0.0 0.0
native 20 fps 0.655 1.000

And the property the design actually needs is not "the loop scores high", it is "no prompt time lets the loop through". I swept it:

attack prompt times swept prompt times where pre is quiet and post is present
printed photo held to camera 19 0
screen replay of a selfie video 28 0
looped flash session 14 0

The loop's pre-prompt alternation never falls below 0.62 at any of the 14 prompt times I tried. There is no "go" I could accidentally pick that hands it a quiet window. It dies by the pre-window check, which is the check that is supposed to kill it.

The same bug, one layer over

Identity was on the same 10 Hz stride. Decision window is 1.5 s, so that is 3 samples, and if the lit frames fell between samples the lit-frame mask came back empty and identity returned None. On one clip that surfaced as a straight false reject.

Now identity runs at native rate too: 30 to 45 samples per decision window, and the report prints the window's mean cosine, the whole-clip mean, and the pre-prompt mean separately, so a drag in any one of them is visible instead of averaged away.

What I changed about how I work

Every rejection report now has to name the check that fired, not just the verdict. identity_below_threshold and no_strobe_after_prompt and strobe_already_running are three different findings, and only the third one is a liveness result.

The general form: a negative is not a measurement until you can say what would have produced a positive. If I cannot describe the sample that would pass, I have not built a detector, I have built a closed door.

Still open

  • No genuine flash-on-face clip yet, so the false-reject side of the strobe protocol has no real human in it. That is the next test.
  • A replay that starts exactly at the prompt (clip-triggered, not looping) would show quiet pre-window and a present post-window. Timing alone cannot separate that from a live person. The honest answer is a physical capture-time signal, like how skin reflects a strobe compared to paper or glass.
  • Counting the flashes at 20 fps still does not work. The strobe aliases to every other frame. I stopped trying to count and switched to presence plus timing.

The code is a Python script, two OpenCV series, one InsightFace model, and a report that prints why.

Top comments (1)

Collapse
 
alice_cv profile image
Alice •

Update from the same gate, after a reviewer pointed at the middle of the design instead of the edge. Two corrections, both measured.

1. The check I described here only asks "did the light alternate after the prompt". I built the attack that makes that worthless: a real recorded session, brightness-modulated to look like a screen playing it back, started exactly at the prompt. The gate said PASS. Identity 0.766, alternation 0.000 before the prompt, 0.659 after. A recording that begins when you say go answers a timing-only test completely.

2. The alternation score, as a share of frames that move, is a frame-rate measurement, not a property of the light. It falls as 2/(samples per cycle): 0.769 at 20fps on my real strobe clip, 0.659 at 30, 0.331 at 60, 0.166 at 120 (simulated with the measured amplitude, 120.6). An honest phone at 60fps sits on the reject line. Steps per second is rate-free: 19.7 at 24fps, 19.8 at 60, 19.8 at 120, and 0.00/s on three no-strobe controls.

What replaced it: the operator calls torch ON four times, at random moments in the same second as the go. The gate now requires (a) identity, (b) a brightness step within 0.18s of every call, (c) no brightness step outside the called windows, (d) an on/off level step >= 20.

Same replay through the new gate: 4 of 4 calls matched by luck (a running strobe has an edge near any time you name), but 51 uncalled steps, so REJECT. A synthetic honest response: 4/4, 0 uncalled, PASS.

Honest limit: no real human has answered the schedule yet. That "honest" sample is brightness modulation of real footage, not a person.