sherlock-2023-10-looksrare-m03

[M-03] Index values selected in _woundRequestFulfilled() are not uniformly distributed

보고서

Summary

_woundRequestFulfilled 함수에서 이번 라운드에 부상당할 에이전트를 랜덤으로 뽑을 때, 이 확률이 균일하게 랜덤으로 분포되지 않는다. 부상당할 에이전트 바로 옆에 있는 인덱스가 선택될 가능성이 높기 때문에 부상당한 에이전트의 분포가 편향될 수 있다.

Keyword

insecure randomness, unfairness, gamefi

Vulnerability

_woundRequestFulfilled 함수에서, 랜덤으로 뽑은 인덱스에 이미 부상당한 에이전트가 있다면 랜덤 숫자를 새로 뽑지 않고 바로 다음 인덱스를 선택한다.

    function _woundRequestFulfilled(
        uint256 roundId,
        uint256 currentRoundAgentsAlive,
        uint256 activeAgents,
        uint256 randomWord
    ) private returns (uint256 woundedAgentsCount) {
        woundedAgentsCount =
            (activeAgents * AGENTS_TO_WOUND_PER_ROUND_IN_BASIS_POINTS) /
            ONE_HUNDRED_PERCENT_IN_BASIS_POINTS;
        // At some point the number of agents to wound will be 0 due to round down, so we set it to 1.
        if (woundedAgentsCount == 0) {
            woundedAgentsCount = 1;
        }
 
        uint256[] memory woundedAgentIds = new uint256[](woundedAgentsCount);
        uint16[MAXIMUM_HEALING_OR_WOUNDED_AGENTS_PER_ROUND_AND_LENGTH]
            storage currentRoundWoundedAgentIds = woundedAgentIdsPerRound[roundId];
 
        for (uint256 i; i < woundedAgentsCount; ) {
            uint256 woundedAgentIndex = (randomWord % currentRoundAgentsAlive).unsafeAdd(1);
            Agent storage agentToWound = agents[woundedAgentIndex];
 
            if (agentToWound.status == AgentStatus.Active) {
                // This is equivalent to
                // agentToWound.status = AgentStatus.Wounded;
                // agentToWound.woundedAt = roundId;
                ...
 
@>              randomWord = _nextRandomWord(randomWord);
            } else {
                // If no agent is wounded using the current random word, increment by 1 and retry.
                // If overflow, it will wrap around to 0.
                unchecked {
@>                  ++randomWord;
                }
            }
        }
        ...
    }

8명의 에이전트에서 랜덤으로 뽑는 상황을 생각해보자. 처음에는 모두가 동일한 확률로 뽑힌다.

Index12345678
AgentsActiveActiveActiveActiveActiveActiveActiveActive
Probabilities0.1250.1250.1250.1250.1250.1250.1250.125

인덱스 2의 에이전트가 부상당하면 인덱스 3의 에이전트가 뽑힐 확률이 2배가 된다. (인덱스를 2 또는 3을 뽑았을 때 뽑히기 때문)

Index12345678
AgentsActiveWoundedActiveActiveActiveActiveActiveActive
Probabilities0.125-0.250.1250.1250.1250.1250.125

인덱스 2와 3이 부상당했다면, 인덱스 4가 뽑힐 확률은 3배가 된다. (인덱스를 2, 3, 4를 뽑았을 때 뽑히기 때문)

Index12345678
AgentsActiveWoundedWoundedActiveActiveActiveActiveActive
Probabilities0.125--0.3750.1250.1250.1250.125

Impact

부상 확률이 균일하지 않다. 부상당한 에이전트의 분포에 불공평함이 생긴다.

Mitigation

매번 새로운 랜덤값을 생성해 사용한다. (_nextRandomWord는 해시를 통해 충분한 랜덤성 제공)

diff --git a/Infiltration.sol b/Infiltration.mod.sol
index 31af961..1c43c31 100644
--- a/Infiltration.sol
+++ b/Infiltration.mod.sol
@@ -1451,15 +1451,9 @@ contract Infiltration is
                    ++i;
                    currentRoundWoundedAgentIds[i] = uint16(woundedAgentId);
                }
-
-                randomWord = _nextRandomWord(randomWord);
-            } else {
-                // If no agent is wounded using the current random word, increment by 1 and retry.
-                // If overflow, it will wrap around to 0.
-                unchecked {
-                    ++randomWord;
-                }
            }
+
+            randomWord = _nextRandomWord(randomWord);
        }
 
        currentRoundWoundedAgentIds[0] = uint16(woundedAgentsCount);

tags: bughunting, looksrare, smart contract, solidity, gamefi, nft, insecure randomness, unfairness, severity medium