LND Postmortem: $1.27 Million Loss
Author: Michael
Summary
On May 9, 2025, LND (LND.fi
) suffered a critical security breach resulting in the loss of approximately $1.27 million in user funds. The incident was triggered by a flawed contract deployment that intentionally expanded the privileges of the Pool Admin role. The deployer 0xc0454e29835479ee80d6f42965a16dcee9bfd868 of LND swept all assets.
Specifically, the deployed AToken
and VariableDebtToken
contract modified the onlyPool
access control modifier in IncentivizedERC20.sol
, allowing the Pool Admin, held by the deployer address 0xc045…fd868, to invoke functions originally restricted to the Pool itself. Leveraging this, the deployer called transferUnderlyingTo
on the AToken
contract and drained all assets.
Timeline
Mar-29-2025 09:51:06 PM UTC, the deployer became the Pool Admin role at txn 0xd03b…a41a.
Mar-29-2025 09:52:23 PM UTC, the deployer initialized the modified
AToken
contract (0xaa8cc9afe14f3a2b200ca25382e7c87cd883a527
) at txn 0x2b24…e8d2.Mar-29-2025 09:52:51 PM UTC, the deployer initialized the modified
VariableDebtToken
contract (0x0b1a51c5cbffc636d79a072b8aa5a763cec42ef2
) at txn 0x22a4…3fe5.May-09-2025 02:29:09 AM UTC, the deployer started draining all pools and deposited most funds to 0x5149…2cdd and 0x40c7…10c8 via a series of
transferUnderlyingTo
invocations, starting from txn 0xd52f…bffe.Most funds were transferred to 0x5149…2cdd, 0x40c7…10c8 and 0xc045…d868.
May-09-2025 02:39:22 AM UTC, 0x5149…2cdd started bridging the stolen funds via a series of txns, started from txn 0xb287…8ee0. Most stolen funds were later bridged and are currently sitting in the following wallets:
(debridged again)
BSC0x82be…14d9(debridged again)
BSC0x8148…2f3e(debridged again)
BSC0x5a94…1434(debridged again)
BSC0x4b82…2fb1BSC 0x9f6b…df5f
BSC 0x114d…c4b5
ETH 0xbd9e…c81f
Transferred to TradeOrge at txn 0x9311…b462.
May-09-2025 09:19:36 AM UTC, the depolyer Pool Admin role was revoked by 0xe82e…aba4 at txn 0x74fa…b913.
Other related incidents:
May-01-2025 07:40:13 PM UTC, the deployer granted 0x9b64…b3f5 the Pool Admin role at txn 0x14af…1560.
May-09-2025 02:35:41 AM UTC, the deployer granted 0x40c7…10c8 the Pool Admin role at txn 0x51ee…95e0.
May-09-2025 08:31:41 AM UTC, the deployer revoked 0x40c7…10c8's the Pool Admin role at txn 0x6d36…d25e8.
May-09-2025 09:16:47 AM UTC, the deployer granted 0xe82e…aba4 the
0x00
role, which is theDEFAULT_ADMIN_ROLE
at txn 0x99fb…2a56.May-09-2025 09:28:13 AM UTC, 0xe82e…aba4 revoked the deployer's
0x00
role, which is theDEFAULT_ADMIN_ROLE
at txn 0xbd43…7d73.May-09-2025 09:31:16 AM UTC, 0xe82e…aba4 revoked 0x9b64…b3f5's the Pool Admin role at txn 0x4cba…7a00.
0x1: onlyPool
access control modifier was compromised
The deployer created a modified AToken
contract (0xaa8cc9afe14f3a2b200ca25382e7c87cd883a527
) where the onlyPool
access control modifier was altered to allow not only the Pool
contract but also any address with the Pool Admin role to invoke restricted functions.
The modified
AToken
contract: https://sonicscan.org/address/0xaa8cc9afe14f3a2b200ca25382e7c87cd883a527#code#F19#L47 ->IncentivizedERC20.sol
. Note that the|| aclManager.isPoolAdmin(msg.sender)
was added.
/**
* @dev Only pool can call functions marked by this modifier.
*/
modifier onlyPool() {
IACLManager aclManager = IACLManager(
_addressesProvider.getACLManager()
);
require(
_msgSender() == address(POOL) || aclManager.isPoolAdmin(msg.sender),
Errors.CALLER_MUST_BE_POOL
);
_;
}
Original AAVE V3: https://github.com/aave-dao/aave-v3-origin/blob/464a0ea5147d204140ceda42a433656a58c8e212/src/contracts/protocol/tokenization/base/IncentivizedERC20.sol#L33-L39
/**
* @dev Only pool can call functions marked by this modifier.
*/
modifier onlyPool() {
require(_msgSender() == address(POOL), Errors.CALLER_MUST_BE_POOL);
_;
}
VariableDebtToken
contract is also modified
The modified
VariableDebtToken
contract: https://sonicscan.org/address/0x0b1a51c5cbffc636d79a072b8aa5a763cec42ef2#code#F20#L47 ->IncentivizedERC20.sol
. Note that the|| aclManager.isPoolAdmin(msg.sender)
was added.
/**
* @dev Only pool can call functions marked by this modifier.
*/
modifier onlyPool() {
IACLManager aclManager = IACLManager(
_addressesProvider.getACLManager()
);
require(
_msgSender() == address(POOL) || aclManager.isPoolAdmin(msg.sender),
Errors.CALLER_MUST_BE_POOL
);
_;
}
Original AAVE V3: https://github.com/aave-dao/aave-v3-origin/blob/464a0ea5147d204140ceda42a433656a58c8e212/src/contracts/protocol/tokenization/base/IncentivizedERC20.sol#L33-L39
This modified contract was also deployed and tested on Sonic by a friend of the deployer
The friend: 0xB8CaE283E7bFFE1F262d8276aBc79f85F9BEAC08.
0x2: The deployer held the Pool Admin role
list of related events: https://sonicscan.org/address/0x97f91ca15ce342ef92b6ca9673f5d5b44528bfa1#events
role granted txn: https://sonicscan.org/tx/0xd03b7d80cf7fcd4d14076ca53d42bcfac0115674699adecb99dd3a769d5ea41a
role revoked txn: https://sonicscan.org/tx/0x74fadb3d2bdbcc215485537b69c8f25c2562981eee37c7014931941bdb39b913
0x3: The deployer called transferUnderlyingTo
and swept all assets
In original AAVE, only Pool can invoke transferUnderlyingTo
and Pool Admin cannot. However, since onlyPool
modifier was compromised, this is now possible.
AToken.sol
contract addr: https://sonicscan.org/address/0xaa8cc9afe14f3a2b200ca25382e7c87cd883a527#code#F1#L156
/// @inheritdoc IAToken
function transferUnderlyingTo(address target, uint256 amount) external virtual override onlyPool {
IERC20(_underlyingAsset).safeTransfer(target, amount);
}