sherlock-2023-10-looksrare-h01

[H-01] killWoundedAgents - re-wounded healed agent die when ROUNDS_TO_BE_WOUNDED_BEFORE_DEAD passed because of not checking woundedAt

보고서

Summary

기존에 치유된 에이전트가 다시 부상을 입으면, 에이전트가 과거에 치유한 부상으로 인해 사망한다. 사용자는 에이전트를 치료하기 위해 LOOKS 토큰을 소비하고 치료에 성공했지만, 결과적으로 에이전트는 사망하게 된다.

Keyword

logic flaw, gamefi

Vulnerability

_killWoundedAgents 함수에서는 에이전트의 상태만 확인하고, 이 상태가 언제 업데이트 된 것인지는 확인하지 않는다.

    function _killWoundedAgents(
        uint256 roundId,
        uint256 currentRoundAgentsAlive
    ) private returns (uint256 deadAgentsCount) {
        ...
        for (uint256 i; i < woundedAgentIdsCount; ) {
            uint256 woundedAgentId = woundedAgentIdsInRound[i.unsafeAdd(1)];
 
            uint256 index = agentIndex(woundedAgentId);
@>          if (agents[index].status == AgentStatus.Wounded) {
                ...
            }
 
            ...
        }
 
        emit Killed(roundId, woundedAgentIds);
    }

따라서 fulfillRandomWords 에서 과거 부상을 치료하지 않은 에이전트를 사망처리 할 때, 과거 부상을 치료한 뒤 다시 부상을 입은 에이전트도 사망한다.

또한, fulfillRandomWords에서는 에이전트를 사망처리 하기 전에 먼저 이번 라운드에 새로 부상당한 에이전트를 뽑는다. 따라서 최악의 경우 에이전트가 이번 라운드에서 부상을 입고 즉시 사망할 수도 있다.

if (activeAgents > NUMBER_OF_SECONDARY_PRIZE_POOL_WINNERS) {
@>  uint256 woundedAgents = _woundRequestFulfilled(
        currentRoundId,
        currentRoundAgentsAlive,
        activeAgents,
        currentRandomWord
    );
 
    uint256 deadAgentsFromKilling;
    if (currentRoundId > ROUNDS_TO_BE_WOUNDED_BEFORE_DEAD) {
@>      deadAgentsFromKilling = _killWoundedAgents({
            roundId: currentRoundId.unsafeSubtract(ROUNDS_TO_BE_WOUNDED_BEFORE_DEAD),
            currentRoundAgentsAlive: currentRoundAgentsAlive
        });
    }

다음은 PoC 코드이다. Infiltration.fulfillRandomWords.t.sol 에 추가하여 실행할 수 있다.

function test_poc() public {
 
    _startGameAndDrawOneRound();
 
    uint256[] memory randomWords = _randomWords();
    uint256[] memory woundedAgentIds;
 
    for (uint256 roundId = 2; roundId <= ROUNDS_TO_BE_WOUNDED_BEFORE_DEAD + 1; roundId++) {
 
        if(roundId == 2) { // heal agent. only woundedAgentIds[0] dead.
            (woundedAgentIds, ) = infiltration.getRoundInfo({roundId: 1});
            assertEq(woundedAgentIds.length, 20);
 
            _drawXRounds(1);
 
            _heal({roundId: 3, woundedAgentIds: woundedAgentIds});
 
            _startNewRound();
 
            // everyone except woundedAgentIds[0] is healed
            uint256 agentIdThatWasKilled = woundedAgentIds[0];
 
            IInfiltration.HealResult[] memory healResults = new IInfiltration.HealResult[](20);
            for (uint256 i; i < 20; i++) {
                healResults[i].agentId = woundedAgentIds[i];
 
                if (woundedAgentIds[i] == agentIdThatWasKilled) {
                    healResults[i].outcome = IInfiltration.HealOutcome.Killed;
                } else {
                    healResults[i].outcome = IInfiltration.HealOutcome.Healed;
                }
            }
 
            expectEmitCheckAll();
            emit HealRequestFulfilled(3, healResults);
 
            expectEmitCheckAll();
            emit RoundStarted(4);
 
            randomWords[0] = (69 * 10_000_000_000) + 9_900_000_000; // survival rate 99%, first one gets killed
 
            vm.prank(VRF_COORDINATOR);
            VRFConsumerBaseV2(address(infiltration)).rawFulfillRandomWords(_computeVrfRequestId(3), randomWords);
 
            for (uint256 i; i < woundedAgentIds.length; i++) {
                if (woundedAgentIds[i] != agentIdThatWasKilled) {
                    _assertHealedAgent(woundedAgentIds[i]);
                }
            }
 
            roundId += 2; // round 2, 3 used for healing
        }
 
        _startNewRound();
 
        // Just so that each round has different random words
        randomWords[0] += roundId;
 
        if (roundId == ROUNDS_TO_BE_WOUNDED_BEFORE_DEAD + 1) { // wounded agents at round 1 are healed, only woundedAgentIds[0] was dead.
            (uint256[] memory woundedAgentIdsFromRound, ) = infiltration.getRoundInfo({
                roundId: uint40(roundId - ROUNDS_TO_BE_WOUNDED_BEFORE_DEAD)
            });
 
            // find re-wounded agent after healed
            uint256[] memory woundedAfterHeal = new uint256[](woundedAgentIds.length);
            uint256 totalWoundedAfterHeal;
            for (uint256 i; i < woundedAgentIds.length; i ++){
                uint256 index = infiltration.agentIndex(woundedAgentIds[i]);
                IInfiltration.Agent memory agent = infiltration.getAgent(index);
                if (agent.status == IInfiltration.AgentStatus.Wounded) {
                    woundedAfterHeal[i] = woundedAgentIds[i]; // re-wounded agent will be killed
                    totalWoundedAfterHeal++;
                }
                else{
                    woundedAfterHeal[i] = 0; // set not wounded again 0
                }
 
            }
            expectEmitCheckAll();
            emit Killed(roundId - ROUNDS_TO_BE_WOUNDED_BEFORE_DEAD, woundedAfterHeal);
        }
 
        expectEmitCheckAll();
        emit RoundStarted(roundId + 1);
 
        uint256 requestId = _computeVrfRequestId(uint64(roundId));
        vm.prank(VRF_COORDINATOR);
        VRFConsumerBaseV2(address(infiltration)).rawFulfillRandomWords(requestId, randomWords);
    }
}

Impact

사용자가 에이전트를 살리기 위해 토큰을 소비했지만, 에이전트 치료에 성공하더라도 에이전트는 사망한다. 사용자는 토큰을 잃고 게임에서 강제 퇴장당한다.

Mitigation

_killWoundedAgents에서 부상당한 시점도 확인한다.

    function _killWoundedAgents(
        uint256 roundId,
        uint256 currentRoundAgentsAlive
    ) private returns (uint256 deadAgentsCount) {
        ...
        for (uint256 i; i < woundedAgentIdsCount; ) {
            uint256 woundedAgentId = woundedAgentIdsInRound[i.unsafeAdd(1)];
 
            uint256 index = agentIndex(woundedAgentId);
-           if (agents[index].status == AgentStatus.Wounded) {
+           if (agents[index].status == AgentStatus.Wounded && agents[index].woundedAt == roundId) {
                ...
            }
 
            ...
        }
 
        emit Killed(roundId, woundedAgentIds);
    }

tags: bughunting, looksrare, smart contract, solidity, gamefi, nft, logic flaw, severity high