Notes / Findings
Source: data/finding_2026-10-01-aave-capo-price-authority.md in the repository, rendered here without changes. Source file
2026-10-01. Tool: scripts/check_lending_price_authority.py (read-only, eth_call only), test scripts/tests/test_check_lending_price_authority.py. Amounts are from the run of about 10:50 UTC. The tool went through two independent reviews that day; the second found that its selector search missed a compiler encoding, which changed one conclusion (see below).
On 10 Mar 2026 an automated agent updated the wstETH CAPO price adapter of Aave V3 Ethereum with no multisig and no timelock. The snapshot ratio and its timestamp ended up misaligned, the cap fell 2.85% under the live rate, and about 27 M$ (some 10,938 wstETH, 34 accounts) were liquidated (Aave post-mortem). The repository scores who controls the Aave pool itself (score_aave_v3_pool), but not who can move the price the pool uses. This is the first measurement of that path.
Each reserve's price source comes from AaveOracle.getSourceOfAsset. A source that answers ACL_MANAGER() is one of Aave's own adapters: its parameters are set by RISK_ADMIN or POOL_ADMIN holders of that ACLManager. A source that reverts there is an external feed (Chainlink and the like), whose own owner is out of scope here.
| Price source kind | Reserves | Sources | Supplied |
|---|---|---|---|
LST CAPO adapter (setter setCapParameters) | 15 | 15 | $8.68B |
Stable CAPO adapter (setter setPriceCap) | 11 | 10 (USDG and PT-USDG share one) | $5.43B |
Older capped adapter, "Capped USDT/USD", used by USDe (setter setPriceCap) | 1 | 1 | $0.56B |
"Capped EURC/USD" ratio-capped adapter (setter setPriceCapRatio) | 1 | 1 | $0.05B |
Pendle PT adapter (setter setDiscountRatePerYear) | 15 | 15 | $0.004B |
"Fixed mUSD/USD" adapter (setter setPrice) | 1 | 1 | about $0 |
| External feed | 23 | 20 | $10.62B |
| All reserves | 67 | $25.34B |
So 44 reserves ($14.71B, 58% of supply) are priced by Aave adapters, each with a known setter. 42 of them answer to the Core ACLManager 0xc2aaCf65...; tETH and ezETH (about $1M) answer to the Prime instance's ACLManager 0x013E2C75....
On each ACLManager, the RISK_ADMIN and POOL_ADMIN holders were replayed from RoleGranted/RoleRevoked since deployment and each confirmed live by hasRole on two RPCs. A holder is a contract that can only do what its code does, so each holder's code (behind one EIP-1967 hop for the two proxies) was searched for the selector of an adapter setter or of a steward entry point that leads to one, in the three forms solc emits that the search knows (pushed whole, as a PUSH32 of the selector already shifted, or shifted at run time: PUSH4 (sel >> k), PUSH1 (224 + k), SHL):
| ACLManager | Holder | Name (aave-permissions-book) | Controlled by | Setter path in code |
|---|---|---|---|---|
| Core | 0x13a9CC64... | Manual AGRS | RISK_COUNCIL 0x47c71dFE... | LST, stable, Pendle (setters and steward entries) |
| Core | 0x6f48d9Cd... | Manual AGRS, second one (granted 30 Sep 2026) | same RISK_COUNCIL | LST, stable, Pendle (setters, shifted form, and steward entries) |
| Core | 0x98217A06... | Core GHO Aave Steward | RISK_COUNCIL 0x8513e6F3... (Safe 2-of-3) | none seen |
| Core | 0x529e2374... | PendleDiscountRateAgent | AgentHub, see below | setDiscountRatePerYear (shifted form) |
| Core | 0x5513224d... | Gho Core Direct Minter (proxy, implementation 3.9 kB) | none seen | |
| Core | 0xbe284044... | EModeCategoryAgent | none seen | |
| Core and Prime | 0x5300A1a1... (POOL_ADMIN) | governance executor | none seen; adapters accept POOL_ADMIN directly (checked on the USDe adapter) | |
| Prime | 0x5BA8d98f... | a steward of the same size as Manual AGRS (16,066 bytes) | same RISK_COUNCIL 0x47c71dFE... | LST, stable, Pendle |
| Prime | 0x2cE01c87... (proxy, implementation 4.6 kB), 0x5C905d62... (council 0x8513e6F3...) | none seen |
The council. 0x47c71dFE... is a Safe 2-of-2 with no module: one bare EOA (0x606dC57c...) and one Safe 1-of-3 (0xb291232F...). So two keys, the EOA and any one of three, drive all three stewards that reach a price. The EOA also sits in the 2-of-6 Safe under the GHO council, and 0xb291232F... is an owner of both councils.
What the update path lets the council do. Read from getRiskConfig() of 0x13a9CC64... and 0x5BA8d98f... (15 limit pairs, layout from aave-dao/aave-v3-risk-stewards IRiskSteward.sol, main branch) and from that repository's RiskSteward.sol:
InvalidPriceCapUpdate(), selector 0x10228354).CapLowerThanActualPrice(), selector 0xeefa4b6e, raised by the adapter).The second Core steward 0x6f48d9Cd... does not answer getRiskConfig(), so its limits were not read. All three were then simulated by eth_call from the council's address (no transaction), each next to a harmless control update:
| Simulated update | Control | 0x13a9CC64... | 0x6f48d9Cd... | 0x5BA8d98f... (Prime) |
|---|---|---|---|---|
| LST snapshot at half the live ratio (weETH; tETH on Prime) | 0.01% under the live ratio: accepted | refused | refused | refused |
| Stable cap 10% lower in one update (USDT) | 0.1% lower: accepted | refused | refused | no stable adapter behind Prime |
| Pendle discount 50 points higher in one update (PT-srUSDe-22OCT2026) | +0.1 point: accepted | refused | refused | no PT adapter behind Prime |
Checked by hand the same day:
InvalidCaller()).getPriceCap(), so both Core stewards' stable path reverts on it. Among today's holders, only the governance executor has a working path to it, per the code search; its direct setPriceCap is accepted in simulation.The March incident went through an AgentHub: an automated agent holding RISK_ADMIN, executing what a risk oracle publishes. That path still exists, with different parties. The AgentHub 0x95E3015c... (owner: the governance executor) has 7 registered agents:
CapoPriceCapUpdate_Core (the CAPO path of March) and the rate-strategy agents.0x683d1A91... ("LlamaRisk PT Risk Oracle (Aave V3 Ethereum Core)"):0x9240a666... lets it move the discount rate by at most 1 point (absolute) per update, with at least 2 days between updates. It has never injected an update.UpdateInjected events of the hub). These change e-mode collateral parameters, not a price.Who can publish to that RiskOracle, read by hand from its getters and from the verified sources on Blockscout:
AuthorizedSenderAdded/Removed, and confirmed by isAuthorized, is 0x1D85000D..., a contract verified as LlamaguardRiskOracleRouter. It accepts reports from a configured Chainlink workflow forwarder (isReportWriteSecured() true).0x1a0267E9..., which also owns the RiskOracle. They set routes, throttles and report age.0x2CFe3ec4..., which can pause. The same Safe is the agents' admin on the hub.0x49cE85E1...) is also an owner of the council's 1-of-3 Safe, and the 4-of-7 counts that 1-of-3 Safe among its owners.Who sets the agents' limits: the agent admin of agents 5 and 6 is the same Safe 4-of-7 0x2CFe3ec4.... It may, in one transaction and with no timelock, widen the range in the range module, add allowed markets, shorten the minimum delay and enable or disable these two agents (onlyOwnerOrAgentAdmin and onlyHubOwnerOrAgentAdmin in the verified AgentConfigurator.sol and RangeValidationModule.sol). The 2-of-4 decides what gets published. So the 1 point and 2 days above are settings, not hard limits. The hard limit is the PT adapter's own MAX_DISCOUNT_RATE_PER_YEAR: 10.22 points on PT-srUSDe-22OCT2026, against 3.77 today, which bounds the automated path at about 0.36% of that PT's price today.
Every adapter, not one per kind. The three council-driven stewards were simulated on every adapter they reach (the tool now does this by default). The council is the same Safe 2-of-2, and each steward was re-confirmed by hasRole on two RPCs; the holder list is that day's replay. The harmful update was refused, and the harmless control accepted, on:
| Kind | Core stewards 0x13a9CC64... and 0x6f48d9Cd... | Prime steward 0x5BA8d98f... |
|---|---|---|
| LST CAPO | 12 of 13 adapters, $8.705B of $8.705B | 2 of 2 |
| Stable CAPO | 10 of 10 (11 reserves: USDG and PT-USDG share one), $5.429B of $5.429B | none behind Prime |
| Pendle PT | 1 of 15: the only PT with supply, $3.5M | none behind Prime |
The cases left INCONCLUSIVE are eUSDe and 14 matured PTs. For each, the control update was refused too, and none of them carries supply. So on every adapter that prices supply, the bound holds where a harmful update is attempted.
The dominant authority is upstream. 45 of the 67 reserves, about 99.4% of supply, are priced through a Chainlink feed owned by the Safe 4-of-9 0x21f73D42..., either directly or as the base of an Aave adapter. The weETH CAPO adapter, for example, reads ETH/USD 0x5424384B... as BASE_TO_USD_AGGREGATOR. That Safe can swap a proxy's aggregator with no delay, and the aggregators' minAnswer is 1. This comes from an independent depth pass of the same day, verified by a second agent. Under the existing composite rule, the path scores 52. It is the number that would set Aave Core's oracleAuthorityScore if the project scored price paths of consumers; whether it does is a methodology decision, not taken here. Today the published value is 100.
Decided later on 2026-10-04. The project now scores price paths of consumers: oracleAuthorityScore is the minimum over the material paths one hop upstream (METHODOLOGY, "oracleAuthorityScore for price consumers"). Aave Core comes out at 52, set by this Safe; the inputs further upstream are listed in data/finding_2026-10-04-price-paths-beyond-one-hop.md.
Not an audit, not investment advice. All notes ยท Back to the dashboard.