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.*

