|
| 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. |
0 commit comments