Cover photo

# Your PoC is lying: the privilege audit

*A green test proves what the harness allowed it to prove. Here is how I now check what mine allowed.*

The test was green, and the mechanism was real.


A public function took a key from the caller and wrote a valuation into whichever slot that key

named. No capability check, no binding between the caller and the slot. Anyone could write a

number into a position they did not own. I had a proof of concept that turned that write into a

fund loss, the assertion passed, and I submitted it.


Before the reply came back, an internal post-mortem pulled the harness apart, and the problem

was in one line I had never looked at twice. **The attacker's write and the protocol's read ran

inside the same transaction, and that transaction belonged to the protocol's operator.**


In the test, that was one function body: the operator refreshes the valuation, the attacker writes

the bad value, the operator's read consumes it, funds move. On chain, the operator's transaction is

signed by the operator. An outsider cannot insert a call into the middle of a transaction someone

else signed. And when I separated the steps into the transactions they would really execute in, my

own fourth test showed the rest: the operator's routine re-valuation overwrote the injected number

before anything read it.


The bug was real. The impact was something my harness had manufactured.


This article is the check I built afterwards. It is short, it runs before any write-up, and it would

have stopped that finding before I wrote the first line of the report.


---


## The checklist


The whole article in one block.


```

PRIVILEGE AUDIT                                    [~15 min per PoC]


SENDERS

  [ ] Every call in the PoC has a named sender

  [ ] Attacker and victim are different addresses

  [ ] The test contract itself is not silently a privileged account


BOUNDARIES

  [ ] Every step is assigned to a transaction, and each transaction to one signer

  [ ] Every attacker step runs in a transaction the attacker signs

  [ ] No transaction both grants privileged context and carries an attacker step

  [ ] Any "same block" step names what guarantees its ordering


PRIVILEGE IDIOMS

  [ ] Every prank, capability or cheatcode that grants authority is listed

  [ ] Each one is either deployment-mirroring setup or a precondition I state openly


CONTROL

  [ ] The attack is re-run with zero privileged context in any attacker step

  [ ] It still moves value, and the assertion measures the attacker's balance

```


---


## Why a PoC lies


A test framework gives you god mode. That is what makes it useful: you can be any address, set any

storage slot, move time, mint any token. Every one of those powers is an assumption about the world,

and none of them is written down unless you write it down.


When a reviewer reads a PoC, the question they are answering is not "does this pass?" It is "could

an outsider do this, from their own wallet, against the deployed protocol?" A harness that skips the

second question proves a mechanism and implies an impact. Triagers are very good at noticing the gap

between those two words.


The lies I have found, in my own tests and in others', fall into five shapes.


1. The leaked prank. `vm.startPrank(owner)` for setup, no `vm.stopPrank()`, and the attacker

steps below it quietly run as the owner. The test passes because the attacker is the owner.


2. The deployer is the owner. The test contract deploys the protocol, so the test contract holds

every role. Any call made without a prank runs with full authority. No grep for `prank` finds this,

because there is no prank.


3. The borrowed transaction. Victim steps and attacker steps sit in one test function, interleaved

in exactly the order the attack needs. That is one execution context. If the victim's step is part of

a transaction the victim signs, the attacker cannot place a call inside it, on any chain I hunt. This

was my case.


4. The convenient block. The attack needs the attacker's transaction directly before or after the

victim's. The test just runs them in sequence. Real ordering needs a reason: a public mempool and a

priority fee, a builder relationship, or a callback the victim's own call makes. "They are in the same

block" is a claim about who orders the block.


5. The god-mode state. `vm.store`, `deal`, `vm.etch`, `vm.mockCall` used to put the protocol in a

state the attacker cannot produce. Sometimes that is honest pre-existing state. Sometimes it is the

attack.


---


## What a lying PoC looks like


A synthetic example. No real protocol, and the shape matches my case on purpose.


```solidity

function test_attackerInflatesValuation() public {

    vm.startPrank(operator);

    vault.refreshValuation(asset);          // the operator's routine update


    vault.writeValuation(asset, 1e30);      // "attacker" step, still pranked as operator

    vault.settle(asset);                    // the read that consumes it

    vm.stopPrank();


    assertGt(vault.totalAssets(), 1e29);    // passes

}

```


Count the lies. The attacker step runs as the operator (shape 1). It runs inside the operator's

sequence, between the operator's own update and the operator's own read (shape 3). And the assertion

measures a protocol-side number, not anything the attacker gained, so even an honest version could

pass without anyone profiting.


The honest version separates every sender and every transaction:


```solidity

function test_attackerInflatesValuation_ownTransactions() public {

    // attacker, own transaction, no privileged context

    vm.prank(attacker);

    vault.writeValuation(asset, 1e30);


    // the operator's routine transaction, exactly as it runs in production

    vm.startPrank(operator);

    vault.refreshValuation(asset);

    vault.settle(asset);

    vm.stopPrank();


    // attacker's value-moving step, own transaction

    uint256 before = token.balanceOf(attacker);

    vm.prank(attacker);

    vault.withdraw(shares);

    assertGt(token.balanceOf(attacker), before, "attacker did not profit");

}

```


In my case, this version failed. The operator's refresh overwrote the injected value before the

read. That failure is the finding's real result, and it is worth more than the green test, because it

is what the triager would have found.


Two Foundry details make the honest version more honest than it looks:


- A test function is one execution context by default. Separate `vm.prank` calls are separate

  calls, not separate transactions. Transient storage, warm-slot gas and refund accounting can

  carry over between them. `forge test --isolate` runs each top-level call as its own transaction.

  If a finding touches transient storage or reentrancy locks, run it isolated.

- `vm.roll` and `vm.warp` are claims too. If two of your steps must land in different blocks,

  roll the block. If they must land in the same block, write down why the attacker gets that

  ordering.


---


## The declaration


The check is not a grep. It is a declaration written next to the PoC, and a set of rules that read

it. Mine looks like this:


```yaml

senders: [attacker, operator, depositor]


boundaries:

  - name: inject

    sender: attacker

    mode: own_transaction

    privileged_context: false

    attacker_steps: true

  - name: routine-settlement

    sender: operator

    mode: own_transaction

    privileged_context: true

    attacker_steps: false

  - name: withdraw

    sender: attacker

    mode: own_transaction

    privileged_context: false

    attacker_steps: true


privileged_helpers:

  - prank-as-privileged-actor    # operator's routine settlement, mirrors deployment

```


`mode` is one of six values: `own_transaction`, `victim_transaction`, `same_transaction_composition`,

`callback_from_victim_call`, `same_block`, `external_trigger`. Choosing one forces the question the

lying test never asked.


The rules that read it are deliberately dumb, so they cannot be argued with:


| Rule | Blocks when |

|---|---|

| Distinct senders | the finding separates attacker from victim, but the PoC has fewer than two senders |

| Attacker control | no boundary is the attacker's own transaction |

| No privileged attacker | any boundary has `privileged_context: true` and `attacker_steps: true` |

| Declared privilege | the harness text contains a privilege idiom that is not in `privileged_helpers` |

| No borrowed transaction | an attacker step is declared inside a victim's transaction on a chain where a transaction has one signer |

| Ordering stated | an attacker step is `same_block` with no ordering guarantee written down |

| Composition owned | the harness composes calls into another actor's transaction with no attacker boundary declared |


A block is not a verdict that the bug is fake. It is a verdict that the PoC has not yet shown the

impact. Some blocked findings are real and need a better harness. Mine needed a different finding.


---


## The grep, for what it is worth


The "declared privilege" rule needs something to compare the declaration against. This is the

pattern list my gate uses, written as one command:


```bash

rg -n \

  -e 'vm\.startPrank\s*\(' \

  -e 'vm\.prank\s*\(\s*(owner|admin|operator|governance|curator|maker)\b' \

  -e 'take_from_(sender|address)\s*<\s*\w*Cap\b' \

  -e '\b(Admin|Operator|Curator|Owner)Cap\b' \

  test/

```


The last two lines cover capability objects in object-capability languages, where authority is a

value you hold rather than an address you are. Taking one from the sender in a test is the same act

as pranking the owner.


And the list I check by eye, which the gate does not enforce yet:


```bash

rg -n -e 'vm\.store' -e '\bdeal\s*\(' -e 'vm\.etch' -e 'vm\.mockCall' -e 'vm\.warp' test/

```


Be honest about what this misses. Shape 2, the test contract as owner, is invisible to any

pattern: there is no idiom to match. Variable names defeat it: `vm.prank(boss)` sails through. That

is why the declaration is the check and the grep is only a cross-reference. The grep catches the

cases where my declaration forgot something. It cannot catch a declaration that lies.


---


## Setup is allowed. Attack steps are not.


The most common objection to all this: "every PoC needs the owner to deploy and configure the

protocol."


Correct. Privileged setup is fine when it mirrors the deployment. If the real protocol's owner

listed this market with these parameters, your harness can do the same as the owner. Declare it, and

check the parameters match what is live.


What is not fine is any privileged context touching an attacker step, or any setup the real owner

never performed. The second one is subtle. If your PoC configures a parameter to a value no

deployment uses, that is a precondition that requires the owner's action, and most programs exclude

findings that need a privileged party to cooperate. In my own rejection log, 5 of 97 rows were closed

on exactly that ground: an admin-gated path or an admin-input validation gap, with no unprivileged

route shown.


The test for the boundary is simple. **Remove every privilege from every attacker step. Does value

still move to the attacker?** If yes, you have a finding. If no, you have a description of what an

owner could do to their own protocol.


---


## What this caught, and what it has not been tested on


Three of my findings once passed every check my tooling had and were marked payable. An independent

review found that each proved a mechanism and not an impact. One was the borrowed transaction above.

One needed a victim to make a design choice no deployed configuration makes. One had no attacker at

all: the only party who could trigger it was the user, against themselves. The privilege audit was

written to catch the first of these. Two companion checks, for the attacker model and for the

configuration a headline number was measured at, were written for the other two.


In the public version of my rejection log, 3 of 97 rows have this realism class as their earliest

catchable gate.


What I cannot claim:


- The false-positive rate is unmeasured. The regression set had three accepted findings, and none

  of them exercised these inputs. The gate has never been scored against a large set of good PoCs.

  It might block real findings with legitimate privileged setup more often than I think.

- The single-signer rule is a chain-model assumption. It holds for every chain I currently hunt.

  Account abstraction, batched user operations and sponsored transactions complicate "one transaction,

  one signer", and a finding that lives in that seam needs its own model, not this rule.

- It is a self-declaration. The rules read what I wrote. An auditor willing to lie in the YAML

  defeats it. The point is to make the honest answer the cheap one, not to make lying impossible.

- It does not find bugs. It decides whether a PoC proves what it claims. That is a smaller job,

  and it is the job that was failing.


---


## What I would do with this if I were you


Before you write the impact section of a report, re-read your PoC and label every line with two

things: who signs this, and which transaction is it in? If any attacker line is signed by someone

else, or sits inside someone else's transaction, stop. Fix the harness before you fix the prose.


If your PoC passes only when the attacker borrows someone's authority, you have found something real.

It is just not the thing your title says.


The rejection patterns that sit next to this check, including the privileged-role and

cooperating-party rules, are public in [viability-gate](https://github.com/KhomDev/viability-gate),

with each rule's intent, signals and known false-positive modes documented in

[docs/rules](https://github.com/KhomDev/viability-gate/tree/main/docs/rules). The categorised

rejection log is in [rejection-taxonomy](https://github.com/KhomDev/rejection-taxonomy).


*If you have a PoC shape that lies in a way this list misses, I would like to see it. Each new shape

becomes a rule.*