Bypassing Facial Verification (KYC) via Response Manipulation

Category: Web Severity: Critical Date: 2026-08-24

Conducted as part of an authorized engagement. The target application and all identifying details have been anonymized per standard disclosure practice. Findings were shared with the client.

A few weeks back I tested a Nigerian financial application for a client. Onboarding a new account follows a now-standard KYC pipeline: you enter your BVN (Bank Verification Number) and NIN (National Identity Number), the app pulls your registered identity data, and then you're asked to take a live selfie. The selfie is matched against the photo attached to your BVN — facial recognition as a proof-of-personhood gate. Only after that match succeeds can you complete account creation.

The entire flow could be bypassed by manipulating a single API response. No biometric forgery, no deepfake, no identity data needed — just editing one field on the wire. This writeup goes deep on why response manipulation works as an attack class, how it played out here, and what the correct fix looks like.

Overview

The verification flow worked like this:

The critical question in any design like this is: who owns the result of the check? In this app, the verification service did perform a genuine face match server-side — but the decision about whether onboarding could proceed lived in the client. The backend never independently re-checked the outcome.

Environment

How Response Manipulation Works

Before the walkthrough, it's worth understanding the attack class properly, because "response manipulation" sounds trivial but rests on a real architectural failure.

The trust boundary problem

In a client-server system, the server is supposed to be the source of truth. Every security decision — is this user authenticated, is this payment valid, does this face match — should be made by the server and enforced by the server. The client is an untrusted rendering layer.

A response manipulation attack works by exploiting the gap between where a decision is computed and where a decision is enforced. When those two locations diverge — the server computes the face-match result, but the app enforces it — the attacker positions themselves in the middle and simply changes the answer.

Man-in-the-middle tooling (Burp, mitmproxy, Frida hooks) lets the attacker rewrite any response the client receives. TLS protects the channel from third parties, but during testing the tester is the endpoint being protected against — the same person whose selfie is being verified. So any decision communicated to the client in plaintext JSON is effectively attacker-editable input.

The vulnerable pattern

The failure usually looks like this:

  1. The server (or a third-party verification service the server fronts) performs a real check and returns an honest result.
  2. The client parses the result and mutates its local state: kycStatus = VERIFIED.
  3. The client's next request — the one that actually completes onboarding — sends its local state to the server, or simply proceeds along a UI-driven path.
  4. The server accepts the final submission without re-validating that the verification actually happened.

Step 4 is the bug. The server provided the response, but the client owns the state that the outcome depends on. In OWASP API terms this is a mix of API3:2019 Excessive Data Exposure (sensitive decision data trusted by the client) and API6:2019 Mass Assignment / client-side state trust — and functionally it's the same family as "client-side price validation" bugs in e-commerce.

Variations of the same bug

Response tampering is one of several ways to exploit this architecture. During the engagement I confirmed the class, not just the instance:

If any of these works, the root cause is identical: the server defers to the client on a decision the server was supposed to own.

The Bypass

With the app proxied through Burp, I walked the onboarding flow and let it fail normally. I submitted a selfie that was clearly not the person attached to the BVN record — a deliberate mismatch, since I wanted to observe the failure response.

The verification request hit the app's backend, which relayed to the face-match service. The honest response came back:

HTTP/1.1 200 OK
Content-Type: application/json

{
  "status": "success",
  "data": {
    "match": false,
    "confidence": 0.21,
    "liveness": "passed",
    "reference": "BVN"
  },
  "message": "Face does not match the photo registered to this BVN"
}

The service did its job: real check, honest answer. The problem is what happens next. Using Burp's response interception (or a match/replace rule for repeatability), the response body was rewritten in flight:

{
  "status": "success",
  "data": {
    "match": true,
    "confidence": 0.99,
    "liveness": "passed",
    "reference": "BVN"
  },
  "message": "Face matches the photo registered to this BVN"
}

The app parsed the tampered response, set its internal KYC state to verified, presented the success screen, and let me continue onboarding. The final account-creation request went to the server — and the server accepted it, because nothing in that request told the backend to re-check the face match. The account was created with a BVN/NIN belonging to one identity and a selfie from another.

No biometric evasion was involved. The face recognition was never wrong — it was simply ignored. That is the essence of the vulnerability: the strongest check in the pipeline can be defeated because its verdict was advisory, not authoritative.

Why This Matters

For a financial application this is a critical-severity finding, not a UI oddity:

How to Fix It

The fix principle is one sentence: the server must perform the check, own the result, and enforce the result — the client only submits evidence, never verdicts.

1. Keep the decision server-side

The client should upload the selfie and nothing else. The server calls the face-verification service itself, and stores the outcome in its own records before the client ever sees a decision:

1. Client  →  POST /kyc/submit-face   (selfie + session id, nothing more)
2. Server  →  calls verification service server-to-server
3. Server  →  stores result in a KYC session record:
              { session_id, kyc_state: VERIFIED | FAILED, expires_at }
4. Client  →  POST /accounts/create    (session id only)
5. Server  →  checks its OWN stored kyc_state for that session
              before creating the account. Always.

The account-creation endpoint must refuse to provision unless the server's internal KYC session state is VERIFIED. The client never sends, receives, or relays a status the server will later trust.

2. If the client must receive a verdict, sign it

Some flows legitimately need to show the client the result. In that case the verification response should carry a short-lived, session-bound token (JWT or HMAC over the result + session id + timestamp) that the final submission endpoint validates server-side. This raises the cost of tampering — but understand its limits:

3. Re-validate at the point of enforcement

Every privileged step — account creation, transaction limits, card issuance — should independently assert that KYC verification completed, by querying the server's own records. Never derive privilege from a client-asserted status field, even one the server originally produced. Defensive-in-depth costs one database lookup per step.

4. Additional hardening

Lessons Learned

The verification vendor worked. The face-matching model worked. The app "worked." The system failed, because a security decision was computed in one place and enforced in another. When you're reviewing any flow where a server hands the client a verdict — payments, KYC, permissions, limits — the review question is always the same: if the client lies about this response, what breaks? If the answer is "the server accepts the lie," you've found the bug before writing a single line of exploit.