Reproduced Exploit

Regnum Aurum: a borrow is checked before interest accrues and the position exceeds its 100 token cap

_borrow calls _validateBorrow before updateReserveState. With usageIndex still 1e18, 99 ether of scaled debt plus a 1 ether borrow is not above the 100 ether cap, so the check passes. One second of interest then moves the index to 1060606060606060606. The old debt is worth 104.999... ether before t…

May 2025•Ethereum•logic•3 min read

Loss

1 rTKN is borrowed after the stale debt reads 99. Accrual then puts the position above the 100 rTKN cap

Chain

Ethereum

Category

logic

Date

May 2025

Source

AuditVault

EVM Playground

Source-level debugger — step opcodes and Solidity in sync

evm-hack-analyzer

The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.

Loading fork state…

Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 66188-h-26-stale-interest-accrual-allows-over-borrowing-pashov-aud. Standalone Foundry PoC and full write-up: 66188-h-26-stale-interest-accrual-allows-over-borrowing-pashov-aud_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/logic/incorrect-order-of-operations · vuln/logic/missing-check

Key info#

FieldValue
Loss1 rTKN is borrowed after the stale debt reads 99. Accrual then puts the position above the 100 rTKN cap
Vulnerable contractLendingPool._borrow
Attacker EOA0x0000000000000000000000000000000000661881 (the borrower)
Attack contractExploit
Attack txExploit.run()
Chain / block / dateEthereum model · block 0 · 2025-05
Compilersolc 0.8.27, cancun, via IR
Bug classCollateral validation uses the pre-accrual usage index

TL;DR#

_borrow calls _validateBorrow before updateReserveState. With usageIndex still 1e18, 99 ether of scaled debt plus a 1 ether borrow is not above the 100 ether cap, so the check passes. One second of interest then moves the index to 1060606060606060606. The old debt is worth 104.999... ether before the new borrow is added, and getPositionDebt ends above 100 ether. The pool still transfers the 1 rTKN. Accruing first makes the same borrow revert and transfers nothing.

Background#

Pashov Audit Group's 6 May 2025 Regnum Aurum review, finding H-26. ReserveLibrary.updateReserveInterests accrues with block.timestamp - lastUpdateTimestamp. The review's example is a 99 token stale debt against a 100 token maximum that becomes about 105 after interest and 106 after the extra borrow.

The vulnerable code#

SOLIDITY
function _borrow(uint256 amount) external {
    // @> VULN: collateral is checked on the stale usage index, then interest accrues
    _validateBorrow(amount);
    updateReserveState();
    scaledDebt[msg.sender] += amount * 1e18 / usageIndex;
    borrowed = amount;
    asset.transfer(msg.sender, amount);
}

lastUpdateTimestamp is 0 and the model block timestamp is 1, so timeDelta is 1 without vm.warp. The per-second rate is 60606060606060606.

Root cause#

The debt read inside _validateBorrow multiplies scaled debt by the stored index. That index is stale until updateReserveState. The check and the accrual are in the wrong order, so the borrower is allowed to add debt that the updated index cannot support.

Preconditions#

  • The borrower already has 99 ether of scaled debt and the index is 1e18.
  • maxDebt is 100 ether.
  • At least one second has passed since lastUpdateTimestamp. The pool holds 10 rTKN of liquidity.

Attack walkthrough#

  1. getPositionDebt returns 99 ether.
  2. _borrow(1 ether) calls _validateBorrow. 100 ether < 99 ether + 1 ether is false.
  3. updateReserveState sets usageIndex to 1060606060606060606.
  4. The new borrow is added on that index and 1 rTKN is transferred to the borrower.
  5. getPositionDebt is greater than 100 ether.
  6. The control calls updateReserveState first. The same 1 ether borrow reverts and the borrower receives 0.

Diagrams#

flowchart TD A["Stale debt reads 99 against a 100 cap"] --> B["Borrower requests 1 more rTKN"] B --> C["VULN: validate runs before accrual"] C --> D["Usage index moves to 1060606060606060606"] D --> E["1 rTKN is transferred"] E --> F["Position debt is above the 100 rTKN cap"]

Sources & further analysis#

Reproductions & code

Alerts & third-party analyses

  • Web3Sec X hacked database: search.
  • Rekt leaderboard: search.
  • Solodit incident search: search.

These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.