自建人臉辨識防偽閘門拒絕所有攻擊,作者發現活體判斷原因有誤
My spoof gate rejected every attack. It was rejecting them for the wrong reason.
作者發現,自建人臉辨識閘門雖拒絕了列印照片、螢幕重播和循環閃光攻擊,活體通道卻因 10 Hz 取樣漏掉約 10 Hz 手機閃光,部分拒絕並非基於預期的活體檢查。
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.
來源:DEV Community · dev.to