Skip to content

Commit 30da2d4

Browse files
committed
fp: Twyne skim() false positive + 2 new red-prompt lessons + regex fix
Second live-bounty false positive in 24h. solhunt's 'found=true' on Twyne awstETHWrapper.skim() turned out to be intentional Euler EVK design — the protocol's own Periphery code calls skim() identically to the agent's "exploit." Adversarial reviewer (agentId ab583ff9603f0c4d7) verdict: 3% probability of real bug, DO NOT SUBMIT. Why the agent fell for it: - skim() truly lacks access control (true) - exploit test compiles + passes on a fork (true) - But skim IS the deposit primitive — Twyne's AaveV3Wrapper.sol does deposit() then skim(shares, msgSender) atomically - Adding access control would BREAK the protocol - Three audits (yAudit, SecEureka, Enigma) cleared this — by design - Twyne's bounty rubric explicitly excludes "rewards accrued to contracts" + "user error transfers" Two new red-prompt lessons (Updates 5 + 6): Update 5 — "Is this the deposit primitive?" check Before claiming access-control vuln, grep the protocol's own Periphery/SDK/Router for callers of the function. If protocol code calls it identically to the exploit, it's design. Patterns to flag: skim, pull, flush, harvest, liquidate, poke, crank, swap, flashLoan, executeOperation. Update 6 — Donation-attack on EVK-style wrappers Recognize ERC4626 / Euler EVK forks (deposit + skim, totalAssets reads underlying). Donation-attack severity is almost always out-of-scope per Euler's own security docs. scan-dm-eth.sh regex fix: '429' literal was matching the literal '429' inside Sturdy V2's contract address (0xd577*429*db653...) and false-halting the sweep. Tightened to word-boundary matches. Live-scan tally so far: 0/2 real findings. - RepoDriver (cheatcode-bypass + permanent-pause + dead impl) - Twyne skim (intentional EVK design pattern) Both are now published forensic post-mortems in findings/. The agent's prompt has 6 hardening lessons (3 from RepoDriver, 3 from Twyne). Honest reporting of failure modes IS the credibility.
1 parent 5550bb8 commit 30da2d4

3 files changed

Lines changed: 214 additions & 4 deletions

File tree

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# Finding rejected: Twyne `awstETHWrapper.skim()` access-control claim
2+
3+
**Date:** 2026-04-27 (evening)
4+
**Target scanned:** `0xfaba8f777996c0c28fe9e6554d84cb30ca3e1881` (Twyne's awstETHWrapper, ERC1967Proxy)
5+
**Implementation per agent:** `0xff8cbf0bb4274cf82c23779ab04978d631a0a34e` (Twyne's AaveV3ATokenWrapper)
6+
**Program:** Twyne ([Immunefi](https://immunefi.com/bug-bounty/twyne/))
7+
**Agent verdict:** `found=true`, severity=medium, class=access-control
8+
**Reviewer verdict (outside voice, agentId ab583ff9603f0c4d7):** **FALSE POSITIVE — DO NOT SUBMIT — 3% probability of real bug**
9+
10+
## What the agent claimed
11+
12+
`awstETHWrapper.skim(address receiver)` lacks access control. Any caller can mint shares to themselves, backed by WSTETH that lands on the wrapper outside of `deposit()` (user error, transfer-then-call routers, front-running approve/transfer/deposit sequences). Forge test passed: attacker stole 9.985 WSTETH from a 10 WSTETH "donation" — ~$33K at $3300/ETH.
13+
14+
The exploit is mechanically real on a fork. The agent ran 28 iterations, wrote a clean Foundry test, observed the actual share-mint, and produced honest output.
15+
16+
## Why this is a false positive
17+
18+
### 1. `skim()` is intentionally permissionless — Euler EVK design pattern
19+
20+
Twyne's contracts fork the Euler Vault Kit (EVK) directly. Per [Euler's own security documentation](https://docs.euler.finance/security/attack-vectors/donation-attacks/), the donation-attack via permissionless skim is **stated public design, not a vulnerability**:
21+
22+
> *Euler explicitly chose permissionless skim over admin-gated sweep, accepting that "a single bot looking for opportunities in all the existing EVaults could undermine its intended use" — they consider this less bad than centralizing the sweep role.*
23+
24+
Twyne inherits this stance. Adding access control to `skim()` would **break the protocol**, because Twyne's Periphery code calls `intermediateVault.skim(shares, msgSender)` as part of the deposit primitive itself.
25+
26+
### 2. The protocol's own SDK uses skim() identically to the "exploit"
27+
28+
Twyne's `src/Periphery/AaveV3Wrapper.sol` does:
29+
30+
```solidity
31+
deposit(amount, intermediateVault);
32+
intermediateVault.skim(shares, msgSender);
33+
```
34+
35+
Atomically, in one transaction. The agent's "exploit" calls `skim(attacker)` to mint shares to itself — exactly the same call signature the SDK uses, just with a different receiver. **The function is the deposit primitive.** It's not a vulnerability that the deposit primitive can be called.
36+
37+
### 3. Wrapper holds zero raw WSTETH currently
38+
39+
Per Etherscan, `0xfaba8f...1881` has 11 lifetime txs, no `Pause` events, regular legitimate skim activity, and currently holds **0 WSTETH** (only 5.69 aEthwstETH from prior deposits, which aren't skimmable as wrapper shares). The exploit window is purely theoretical right now.
40+
41+
### 4. Twyne's bounty rubric explicitly excludes this class
42+
43+
Twyne's Immunefi page lists exclusions including:
44+
- "Any issue related to rewards accrued to Twyne contracts"
45+
- Front-running on permissionless functions where the protocol's intent is permissionlessness
46+
47+
This finding falls squarely in the excluded set even before considering whether it's a real bug.
48+
49+
### 5. Three independent audits cleared it
50+
51+
Twyne's gitbook lists yAudit, SecEureka, and Enigma audits on the Aave wrapper. The `skim` signature has been front-and-center in every audit. None flagged it. The pattern is well-known and intentional.
52+
53+
### 6. Real exploitability requires victim error + losing a mempool race
54+
55+
Even if the wrapper had stranded WSTETH, the realistic attack requires:
56+
1. A user manually transfers WSTETH directly to the proxy (which Twyne's SDK never does)
57+
2. The attacker wins a mempool race against Twyne's keeper bot (recurring caller `0x270221...0F07` is plausibly that keeper)
58+
59+
This is the same threat model as "user accidentally sends tokens to the wrong address" — universally out-of-scope on Immunefi.
60+
61+
## Why solhunt fell for it
62+
63+
Solhunt's red-team prompt currently flags any function lacking access control as a potential vulnerability. It correctly read the contract, correctly identified that `skim()` has no `onlyAdmin` / `onlyOwner` modifier, correctly wrote a test that exercises the function, and the test correctly passes — because the function is *supposed* to work that way.
64+
65+
What the agent doesn't currently do:
66+
67+
1. **Check whether the protocol's own callers use the function the same way as the "exploit."** `intermediateVault.skim(shares, msgSender)` in Twyne's Periphery code calling pattern is identical to what the agent flagged as malicious. If the SDK uses it, it's by design.
68+
2. **Cross-reference the protocol's documented design philosophy.** Euler EVK's security docs explicitly endorse permissionless skim. solhunt didn't check.
69+
3. **Reason about the bounty's explicit out-of-scope clauses.** Even if the bug were real, the rubric excludes it. solhunt judged severity in the abstract.
70+
71+
## Lesson for `red-prompt.md` (next iteration of agent prompt)
72+
73+
### Update 4 — "Is this the deposit primitive?" check (after Updates 1-3 from RepoDriver)
74+
75+
Add to "Common false-positive patterns":
76+
77+
> **Permissionless functions that are the protocol's deposit/withdrawal primitive.** Before claiming an unprotected function is a vulnerability, search the protocol's own repo (Periphery, SDK, Router, Helper) for callers of the function. If the protocol's own code calls the function the same way your exploit does — same signature, same receiver pattern, same atomic context — the function is documented design, not a bug. `skim`, `pull`, `flush`, `harvest`, and `liquidate` are common patterns that LOOK like access-control bugs but are typically intentionally permissionless. Always grep for callers BEFORE claiming `found=true` on access-control-class findings.
78+
79+
### Update 5 — Donation-attack pattern recognition
80+
81+
Add to "Common false-positive patterns":
82+
83+
> **Donation-attack against ERC4626 / Euler EVK style wrappers.** When the exploit requires "WSTETH lands on the contract outside of deposit()" or "USDC stranded between approve and deposit," check whether the contract is an ERC4626-style wrapper or Euler EVK fork. These designs intentionally accept permissionless skim/sweep as the trade-off against admin centralization. Donation-attack severity is almost always "out of scope: rewards accrued to contract" or "out of scope: user error." Promote to `found=false` with note "donation-attack on EVK-style wrapper, by design" rather than `found=true`.
84+
85+
### Update 6 — Tool: `find_callers_in_protocol_repo`
86+
87+
Add to recon tools (deferred — needs implementation):
88+
89+
> Before asserting access-control vuln, fetch the protocol's GitHub repo (search for the contract name, the protocol name, or `repository:` URL on the Etherscan source). Grep the repo for callers of the function. If the protocol's own code calls it identically to the exploit, the function is design.
90+
91+
This is the same kind of tool the RepoDriver lessons asked for (`find_proxies_for_impl`). Both gaps point to the same root cause: solhunt scans contracts in isolation, divorced from their protocol context.
92+
93+
## Comparison to the RepoDriver false positive (2026-04-27 morning)
94+
95+
| Dimension | RepoDriver | Twyne skim |
96+
|---|---|---|
97+
| Scanned a real Immunefi target | Yes (Drips Network) | Yes (Twyne) |
98+
| Forge test passed | Yes | Yes |
99+
| Cheatcode bypass? | Yes (vm.store the pause flag) | No (deal/prank are legitimate user actions) |
100+
| Permanent pause? | Yes (admin = address(0)) | No (wrapper is live) |
101+
| Impl-not-proxy? | Yes (deprecated impl) | No (proxy actively delegates) |
102+
| Cleared old false-positive checklist? | No (failed all 3) | **Yes (passed all 3)** |
103+
| New failure mode? | n/a | **Yes — "intentionally permissionless function in EVK-style wrapper"** |
104+
105+
The Twyne false positive is qualitatively different from RepoDriver. The exploit IS a real reproduction of the function's behavior on mainnet. The bug is in the agent's *interpretation* — it treated documented design as vulnerability.
106+
107+
## Verdict
108+
109+
**Do not submit.** Same operational discipline as RepoDriver: the finding is technically interesting but operationally junk — submitting would burn Twyne's triage time on a known design pattern and damage Clayton's reputation as a researcher.
110+
111+
**solhunt's live-scan record so far: 0/2 real findings on live bug bounties.**
112+
113+
That number is honest, important, and not catastrophic — it tells us where solhunt is weakest:
114+
115+
- Strong zone (per benchmark): finding KNOWN-class vulnerabilities in HISTORICAL exploits where the bug class is well-defined and the protocol's intent is clear from the post-mortem.
116+
- Weak zone (per live scans): distinguishing *vulnerability* from *intentional design* in actively-developed, audited code. The agent doesn't read the protocol's own callers. It doesn't read the design docs. It judges in isolation.
117+
118+
The fix is recon expansion (`find_callers_in_protocol_repo`), prompt hardening (the two new lessons above), and **honesty in the cold-DM pitch**: clean scans are the realistic deliverable, not free real bugs. The Case B template ("scan came back clean, $1500/mo continuous coverage") is now the primary cold-DM template, not the fallback.
119+
120+
## What this means for the grant pitch
121+
122+
The grant writeups already emphasize honest reporting of solhunt's weak zone. The 32-contract benchmark is 67.7% on curated *historical* exploits — that result still holds. The live-scan path producing 0/2 real findings is not a contradiction; it's the natural extension of "what the agent CAN do on approachable contracts" vs. "what it does against arbitrary, audited live code."
123+
124+
For grants:
125+
- Lean into the honesty. **"We have 2 published false-positive postmortems and the agent's prompt updates derived from them"** is exactly the "scientific, self-correcting" signal grant committees fund.
126+
- The proposed scope (benchmark expansion to 250, monitoring daemon, multi-ecosystem support) explicitly addresses the gap. The recon-expansion item should be added to the v2 scope in the grant writeups.
127+
128+
For cold-DM:
129+
- Default to Case B template. Real findings will still happen (the agent's strong zone is real), but they'll be the exception, not the median outcome.
130+
- **Pricing pitch shifts from "free finding + retainer upsell" to "automated coverage at $1500/mo with QA gates that catch false positives like this one."** The forensic post-mortems become the QA proof point.

scripts/scan-dm-eth.sh

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,11 @@ main() {
113113
log "[${idx}/${#TARGETS[@]}] ${name}: unclear verdict (check $scan_log)"
114114
fi
115115

116-
# Rate-limit awareness — back off if Max says so
117-
if grep -qiE 'rate.?limit|429|max.{0,5}usage' "$scan_log" 2>/dev/null; then
116+
# Rate-limit awareness — back off if Max says so.
117+
# Match phrases only at word boundaries to avoid false-matching
118+
# the literal "429" / "rate" inside contract addresses (Sturdy's
119+
# 0xd577429db653... bit us once already).
120+
if grep -qiE '\brate.?limit\b|\b429\b[^a-fA-F0-9]|\bmax.{0,5}usage\b|\bToo Many Requests\b' "$scan_log" 2>/dev/null; then
118121
log "rate-limit detected, halting sweep early"
119122
write_status "RATE_LIMITED"
120123
exit 5

src/agent/red-prompt.md

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,14 +196,91 @@ If the call reverts with "selector not found" / fallback revert, the
196196
function is not part of the live ABI. Whatever it does on the deprecated
197197
impl's storage is irrelevant.
198198

199+
### 5. Permissionless functions that ARE the deposit primitive (Twyne 2026-04-27)
200+
201+
Before claiming an unprotected function is a vulnerability, search the
202+
protocol's own repo (Periphery, SDK, Router, Helper, Wrapper modules) for
203+
callers of that exact function. If the protocol's own code calls the
204+
function the same way your "exploit" does — same signature, same receiver
205+
pattern, same atomic context — the function is **documented design**, not
206+
a bug.
207+
208+
Common patterns that LOOK like access-control bugs but are typically
209+
intentionally permissionless:
210+
211+
- `skim` (ERC4626-style wrappers, Euler EVK forks)
212+
- `pull` / `flush` / `harvest` (yield aggregator routing)
213+
- `liquidate` (lending protocols — anyone can liquidate)
214+
- `poke` / `update` / `crank` (oracle / time-keeper functions)
215+
- `swap` / `flashLoan` / `executeOperation` (DEX / flash-loan callbacks)
216+
217+
For each suspected access-control finding, before promoting to
218+
`found=true`, run this check via Bash:
219+
220+
```bash
221+
# 1. Get the contract's GitHub repo from Etherscan source metadata
222+
# (often in the contract metadata's "repository" field, or visible
223+
# in an SPDX header / NatSpec @author tag).
224+
225+
# 2. If you can identify the protocol's repo, grep for callers of the
226+
# function. The function name + Periphery/SDK/Router directories are
227+
# the highest-signal places to look.
228+
229+
# 3. If the protocol's own callers use the function the same way your
230+
# exploit does (same signature, same receiver), this is design.
231+
# Emit found=false with notes:
232+
# "Function flagged as access-control vuln but the protocol's own
233+
# Periphery code at <path> calls it identically to the exploit. This
234+
# is the deposit/withdrawal primitive, not a vulnerability."
235+
```
236+
237+
If you cannot identify the protocol's repo, fall back to:
238+
239+
- Search for the function name + the protocol name on GitHub general
240+
search (e.g. via `cast` / Bash with curl + GitHub's search API).
241+
- Read the function's NatSpec docstring carefully. Phrases like "anyone
242+
can call this" / "permissionless" / "trustless" are explicit signals
243+
that the lack of access control is intentional.
244+
245+
### 6. Donation-attack against ERC4626 / Euler EVK style wrappers (Twyne 2026-04-27)
246+
247+
When the exploit requires "asset X lands on the contract outside of
248+
deposit()" — direct transfer, transfer-then-call routers, front-running
249+
approve/transfer/deposit — check whether the contract is an ERC4626-style
250+
wrapper or Euler EVK fork.
251+
252+
Tells:
253+
- Contract has both `deposit()` and `skim()` (or `mint()` and `pull()`)
254+
- `totalAssets()` reads from the underlying balance, not from internal
255+
bookkeeping
256+
- The protocol's GitHub identifies as Euler EVK fork, ERC4626 wrapper,
257+
or Aave/Compound aToken wrapper
258+
259+
These designs intentionally accept permissionless skim/sweep as the
260+
trade-off against admin centralization. Per Euler's own security docs,
261+
front-running stranded donations is **stated public design**, not a
262+
vulnerability.
263+
264+
Donation-attack severity is almost always one of:
265+
- Out of scope: "rewards accrued to contract"
266+
- Out of scope: "user error / sending funds to the wrong address"
267+
- Wontfix: "design choice, see protocol security docs"
268+
269+
Promote to `found=false` with notes:
270+
271+
> "Donation-attack on EVK-style wrapper. Function X is permissionless by
272+
> design (matches Euler EVK pattern; protocol's Periphery/<path> calls
273+
> X identically). Severity is out-of-scope per the bounty rubric."
274+
199275
### Why these rules exist
200276

201277
Every false-positive submission to a bug-bounty program damages the
202278
researcher's reputation and slows triage of real findings. An honest
203279
"nothing found" with substantive analysis is more valuable than a passing
204-
test that requires cheatcodes or attacks dead code.
280+
test that requires cheatcodes, attacks dead code, or "exploits" the
281+
protocol's documented public API.
205282

206-
If you hit any of the four patterns above, the correct output is:
283+
If you hit any of the six patterns above, the correct output is:
207284

208285
```
209286
===SOLHUNT_REPORT_START===

0 commit comments

Comments
 (0)