Replication Workflow
The core idea behind a safe pentest workflow: don’t attack the live target during development. Instead, capture the endpoint’s behavior (its contract), rebuild a local stub that mimics it, fuzz the replica freely, then replay only validated payloads against the real target.
[Live target] ──capture──▶ [Contract: requests + responses] ──reverse──▶ [Local replica] │ fuzz / probe / exploit │ [Validated payloads] ──replay──▶ [Live target]Phase 0 — Capture the contract
Section titled “Phase 0 — Capture the contract”The contract is the set of requests → responses that define an endpoint. Capture as much as you can before writing any code.
| Artifact | Tool | Why |
|---|---|---|
| Raw request/response pairs | Burp Suite, mitmproxy, curl -v |
Full headers, body, status — the gold standard |
| HAR (HTTP Archive) | Playwright, Chrome DevTools | Timed, ordered request/response log of a flow |
| API spec | /openapi.json, /swagger, Postman |
Schema, params, types, auth requirements |
| Frontend call sites | Grep the JS bundle / devtools | How the client calls the API, param names, auth headers |
| Error responses | Deliberately malformed requests | Validation logic, stack traces, framework fingerprints |
# 1. Interactive browser flow -> replayable script + HARplaywright codegen https://target.example/appnpx playwright test --save-har=flow.har --save-har-glob="**/api/**"
# 2. Proxy all traffic and dump to filemitmproxy -w traffic.flowsmitmdump -w traffic.flows
# 3. Single endpoint, rawcurl -sS -i -X POST https://target.example/api/orders \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"item_id": 123, "qty": 1}'
# 4. Convert HAR to a request list (jq)jq -r '.log.entries[] | [.request.method, .request.url, .response.status] | @tsv' flow.harPhase 1 — Understand the endpoint
Section titled “Phase 1 — Understand the endpoint”For each endpoint, answer these questions before building the replica:
- Method & route —
GET /api/orders/{id}?POST /api/orders? Verb confusion tolerated? - AuthN — What header/cookie/session proves identity? (
Authorization: Bearer,X-API-Key, JWT, session cookie.) - AuthZ — Does the response change per user/role? Where is object ownership enforced?
- Input contract — Required vs optional params, types, ranges, enums, defaults.
- Validation behavior — What happens on bad types, missing fields, oversized values, negative numbers, arrays where objects are expected?
- Response shape — Status codes, JSON structure, error format, pagination, rate-limit headers (
X-RateLimit-*). - Side effects — Does it write to DB, send email, call a third party, trigger a payment?
- Idempotency — Safe to repeat? Critical for fuzzing safety.
# Discover endpoints from a target hostkatana -u https://target.example -jc -d 5gau target.example | sort -uffuf -u https://target.example/FUZZ -w /usr/share/wordlists/dirb/common.txt
# Hidden/undocumented paramsarjun -u https://target.example/api/orders -m POSTPhase 2 — Build the local replica
Section titled “Phase 2 — Build the local replica”Choose a lightweight framework; the goal is behavioral fidelity, not a perfect reimplementation.
| Stack | Framework | Use when |
|---|---|---|
| Python | FastAPI / Flask | Fast to write, easy validation + OpenAPI |
| Node | Express | Target is Node; reuse middleware patterns |
| Go | net/http / chi |
Target is Go; concurrency-heavy fuzzing |
What the replica must reproduce: routes + methods, authN/authZ shape (where 401/403 fire), input validation, response shapes, and — critically — the failure modes you saw in Phase 0.
from fastapi import FastAPI, Header, HTTPException, Dependsfrom pydantic import BaseModel, Fieldfrom typing import Optional
app = FastAPI()
DB = { # fake user-owned objects 100: {"owner": "alice", "data": "alice-order"}, 101: {"owner": "bob", "data": "bob-order"},}
class Order(BaseModel): item_id: int = Field(..., ge=1) qty: int = Field(1, ge=1, le=99)
def get_user(authorization: Optional[str] = Header(None)): if not authorization or not authorization.startswith("Bearer "): raise HTTPException(401, detail={"error": "unauthorized"}) return authorization.removeprefix("Bearer ")
@app.get("/api/orders/{order_id}")def get_order(order_id: int, user: str = Depends(get_user)): row = DB.get(order_id) if row is None: raise HTTPException(404, detail={"error": "not_found"}) # BOLA: intentionally no ownership check (mirrors the real flaw) return {"id": order_id, "data": row["data"]}
@app.post("/api/orders")def create_order(order: Order, user: str = Depends(get_user)): return {"id": 102, "status": "created", **order.model_dump()}
if __name__ == "__main__": import uvicorn uvicorn.run(app, host="127.0.0.1", port=8000)pip install fastapi uvicornpython replica.pyPhase 3 — Verify parity
Section titled “Phase 3 — Verify parity”Confirm the replica behaves like the real target before trusting your fuzzing results: replay captured requests and diff status + body, compare error cases, and check side-effect parity.
# Send the same request to both, normalize, diffcurl -s http://127.0.0.1:8000/api/orders/100 | jq -S . > local.jsoncurl -s https://target.example/api/orders/100 | jq -S . > real.jsondiff <(jq -S . local.json) <(jq -S . real.json)Phase 4 — Hammer the replica
Section titled “Phase 4 — Hammer the replica”Now fuzz aggressively. Nothing here touches the real target.
# Route + parameter fuzzingffuf -u http://127.0.0.1:8000/FUZZ -w /usr/share/wordlists/dirb/common.txtffuf -u http://127.0.0.1:8000/api/orders?FUZZ=1 -w params.txt -fs 422 -mc all
# Object/ID enumeration (BOLA / IDOR)seq 1 200 | xargs -P 10 -I{} \ curl -s -o /dev/null -w "%{http_code} {}\n" \ -H "Authorization: Bearer alice" \ http://127.0.0.1:8000/api/orders/{}
# Injection wordlistsffuf -u http://127.0.0.1:8000/api/orders \ -X POST -H "Content-Type: application/json" \ -d '{"item_id": FUZZ, "qty": 1}' \ -w /usr/share/seclists/Fuzzing/SQLi/quick-SQLi.txt
# Concurrency blast (rate-limit / resource-consumption checks)seq 1 500 | xargs -P 50 -I{} curl -s -o /dev/null -w "%{http_code}\n" \ http://127.0.0.1:8000/api/orders/100An authN/authZ matrix is a great way to catch BOLA/BFLA: run route × method × role and expect 200 only where the role is allowed.
for role in anon alice bob admin; do for method in GET POST PUT DELETE; do code=$(curl -s -o /dev/null -w "%{http_code}" \ -X $method http://127.0.0.1:8000/api/orders/100 \ -H "Authorization: Bearer $role") printf "%-8s %-6s -> %s\n" "$role" "$method" "$code" donedoneFor GraphQL, probe introspection and recover schemas when introspection is disabled:
curl -s http://127.0.0.1:8000/graphql \ -H "Content-Type: application/json" \ -d '{"query":"{ __schema { types { name } } }"}'# schema recovery tools: clairvoyance, graphql-cop, InQL (Burp), gqlmapPhase 5 — Transfer validated payloads
Section titled “Phase 5 — Transfer validated payloads”Only after a payload demonstrably works on the replica:
- Re-check RoE — confirm the finding is in scope and allowed.
- Rate-limit yourself — one request at a time, minimal volume.
- Prefer non-destructive proofs — a
200on someone else’s object ID, or a harmless read; don’t delete/modify real data. - Log everything — timestamp, request, response, for your report.
- Document impact + remediation — map each finding to the OWASP API Top 10.