Back to Blog

Radiant V2 보안 테스트 보고서

Code Auditing
March 23, 2023
31 min read

보고서 매니페스트

항목 설명
고객 Radiant Capital
대상 Radiant V2

버전 이력

버전 날짜 설명
1.0 2023년 3월 15일 최초 버전
2.0 2023년 3월 21일 두 번째 버전

1. 소개

1.1 보안 테스트에 대하여

저희는 Radiant Capital의 요청을 받아 Radiant V2의 스마트 컨트랙트에 대한 보안 테스트(레드팀으로서)를 수행하여 잠재적 위험을 식별하였습니다. 책임감 있는 팀으로서 Radiant Capital은 보안을 중요하게 여기고 있습니다. 따라서 해당 팀은 여러 보안 회사로부터 감사를 받은 스마트 컨트랙트의 보안을 강화하기 위해 더 많은 노력을 기울이기로 결정하였습니다^1.

보안 테스트는 목표와 요구 사항 모두에서 보안 감사와 다릅니다. 구체적으로, 보안 테스트는 공격자를 모방하여 프로그램/프로토콜을 침해함으로써 추가적이거나 비정상적인 취약 지점을 발견하는 것을 목표로 하는 반면, 보안 감사는 가능한 공격 표면을 열거함으로써 비교적 포괄적인 보안 검사를 수행하는 것을 목표로 합니다. 따라서 보안 테스트는 제한된 시간과 자원으로 인해 보안 감사에서 식별될 수 있는 일부 복잡한 로직 버그를 다루지 못할 수도 있습니다.

1.2 대상 컨트랙트에 대하여

정보 설명
유형 스마트 컨트랙트
언어 Solidity
접근 방식 정적 분석, 동적 분석, 반자동 및 수동 검증

대상 저장소는 Radiant_v2.1.1입니다. 보안 테스트 기간 동안의 커밋 SHA 값은 이하와 같습니다. 본 보고서는 초기 버전(즉, 버전 1)과 보고서의 문제를 수정하기 위한 새로운 코드에 대해서만 책임을 집니다.

본 보고서는 이 저장소의 radiant_v2.1.1/contracts 폴더 아래의 스마트 컨트랙트만을 대상으로 하며, 다음을 포함합니다:

  • bounties
  • deployments
  • flashloan
  • leverage
  • lock
  • oracles
  • staking
  • zap
  • eligibility
  • misc
  • oft
  • protocol
  • stargate

버전 8 업데이트 이후, 본 보안 테스트에서 다루는 파일은 다음과 같습니다:

  • lending/AaveOracle.sol
  • lending/AaveProtocolDataProvider.sol
  • lending/ATokensAndRatesHelper.sol
  • lending/StableAndVariableTokensHelper.sol
  • lending/UiPoolDataProviderV2V3.sol
  • lending/UiPoolDataProvider.sol
  • lending/WETHGateway.sol
  • lending/WalletBalanceProvider.sol
  • lending/configuration
  • lending/flashloan
  • lending/lendingpool
  • lending/tokenization
  • radiant/accessories
  • radiant/eligibility
  • radiant/oracles
  • radiant/staking
  • radiant/token
  • radiant/zap

1.3 보안 모델

위험을 평가하기 위해, 저희는 OWASP 위험 등급 방법론^2 및 공통 취약점 열거^3를 포함하여 산업계와 학계에서 광범위하게 채택된 표준 또는 권고 사항을 따릅니다. 위험의 전반적인 심각도가능성영향에 의해 결정됩니다. 구체적으로, 가능성은 특정 취약점이 공격자에 의해 발견되고 악용될 가능성을 추정하는 데 사용되며, 영향은 성공적인 악용의 결과를 측정하는 데 사용됩니다.

본 보고서에서 가능성과 영향은 각각 높음낮음 두 가지 등급으로 분류되며, 그 조합은 표 1.1에 나타나 있습니다.

따라서 본 보고서에서 측정된 심각도는 세 가지 범주로 분류됩니다: 높음, 중간, 낮음. 완전성을 위해, 위험을 잘 판단할 수 없는 상황을 포함하기 위해 미확정도 사용됩니다.

또한, 발견된 항목의 상태는 다음 네 가지 범주 중 하나에 속합니다:

  • 미확정 아직 응답 없음.

  • 인지됨 해당 항목이 고객에게 전달되었으나 아직 확인되지 않음.

  • 확인됨 해당 항목이 고객에게 인식되었으나 아직 수정되지 않음.

  • 수정됨 해당 항목이 고객에 의해 확인 및 수정됨.

2. 자동화 보안 테스트

2.1 자동화 정적 보안 테스트

저희는 Slither 기반의 자체 정적 분석 도구를 사용하여 취약점의 존재 여부를 확인하였습니다. 결과를 수동으로 검토한 후, 어떠한 문제도 발견되지 않았습니다. 자세한 테스트 결과는 부록의 표 4.1에서 확인할 수 있습니다.

2.2 자동화 동적 보안 테스트

저희는 퍼징 기법을 활용하여 대상 컨트랙트의 견고성, 신뢰성 및 정밀도를 테스트하였습니다. 구체적으로, 퍼징 프로세스의 초기 시드는 함수 시맨틱과 컨트랙트 테스트 스크립트를 기반으로 결정됩니다. 온체인 환경을 시뮬레이션하기 위해, 컨트랙트 LendingPool 및 MultiFeeDistribution과 상호작용한 주소 집합도 유지합니다.

저희 퍼저는 트랜잭션 시퀀스 생성 중 함수 시맨틱도 고려합니다. 예를 들어, 컨트랙트 MultiFeeDistribution의 함수 stake와 컨트랙트 LendingPool의 함수 deposit은 시퀀스에서 먼저 호출될 가능성이 높습니다. 함수 매개변수 및 시퀀스의 변이는 컨트랙트 코드 커버리지에 의해 안내됩니다. 특정 매개변수 또는 시퀀스가 더 높은 코드 커버리지에 도달하면, 다음 퍼징 라운드에서 변이될 우선순위가 높아집니다. 매직 넘버로 제한된 일부 경로를 탐색하기 위해, 런타임에 스토리지(즉, SLOAD 명령어)에서 읽은 값을 수집하고 변이 프로세스 중 함수 매개변수를 생성하는 데 활용합니다.

총 100,000개의 테스트 케이스를 생성하고 31개의 오라클을 활용하여 오류 발생 여부를 감지하였습니다. 각 테스트 케이스에는 지정된 순서를 가진 30개의 트랜잭션이 포함되어 있습니다. 최종적으로, 수동 보안 테스트 과정에서도 발견된 중요한 이슈 하나(즉, 섹션 3.2.6)를 발견하였습니다. 자세한 테스트 결과는 부록의 표 4.2, 4.3, 4.4에서 확인할 수 있습니다.

3. 수동 보안 테스트

저희는 전반적인 설계와 서로 다른 모듈 간의 상호작용을 이해하기 위해 수동 작업을 수행하고, 이전 연구 및 경험에서 도출된 잠재적 공격 표면에 대한 전문 지식을 바탕으로 보안 테스트를 실시하였습니다.

17개의 잠재적 이슈를 발견하였습니다. 또한, 3개의 권고 사항과 1개의 참고 사항은 다음과 같습니다:

  • 높은 위험: 2

  • 중간 위험: 8

  • 낮은 위험: 7

  • 권고 사항: 3

  • 참고 사항: 1

ID 심각도 설명 분류 상태
1 중간 함수 포인터 재설정을 위한 예약된 인터페이스 없음 소프트웨어 보안 수정됨
2 중간 오라클의 부적절한 계산 DeFi 보안 수정됨
3 높음 BaseBounty를 통한 잠재적 자금 탈취 DeFi 보안 수정됨
4 낮음 잠재적으로 유효하지 않은 발행 일정 DeFi 보안 수정됨
5 낮음 건너뛸 수 있는 발행 일정 DeFi 보안 확인됨
6 중간 마이그레이션 중 변경 가능한 환율 DeFi 보안 수정됨
7 높음 _transfer()의 부적절한 구현 (I) DeFi 보안 수정됨
8 낮음 UniV2TwapOracle에서 Period 검사 누락 DeFi 보안 수정됨
9 중간 반환되지 않는 먼지 토큰 DeFi 보안 수정됨
10 중간 _transfer()의 부적절한 구현 (II) DeFi 보안 수정됨
11 중간 조작 가능한 컴파운드 보상 DeFi 보안 수정됨
12 중간 setLeverager()의 접근 제어 부재 DeFi 보안 수정됨
13 중간 addLiquidityWETHOnly()의 슬리피지 검사 없음 DeFi 보안 확인됨
14 낮음 loopETH()에서 borrowRatio 검사 누락 DeFi 보안 수정됨
15 낮음 setPoolIDs()에서 assets와 poolIDs 길이 검사 누락 DeFi 보안 수정됨
16 낮음 addBountyContract()에서 mint 권한 취소 누락 DeFi 보안 확인됨
17 낮음 발행자는 한 번만 지정 가능 DeFi 보안 확인됨
18 - 가스 최적화 (Mfd의 zapVestingToLp()) 권고 사항 수정됨
19 - BountyManager의 비어있지 않은 바운티 예비금 권고 사항 수정됨
20 - requiredUsdValue()의 일관성 없는 명명 권고 사항 확인됨
21 - 더 이상 사용되지 않는 MFDPlus 참고 사항 참고 사항 확인됨

세부 사항은 다음 섹션에서 제공됩니다.

3.1 소프트웨어 보안

3.1.1 잠재적 이슈 1: 함수 포인터 재설정을 위한 예약된 인터페이스 없음

항목 설명
심각도 중간
상태 버전 7에서 수정됨
도입된 버전 버전 1

설명 세 가지 함수인 getLpMfdBounty(), getChefBounty(), getAutoCompoundBounty()는 컨트랙트 BountyManager에서 함수 포인터를 통해 호출됩니다. 한편, OwnableUpgradable로부터의 상속은 이 컨트랙트가 프록시의 구현체가 될 것임을 보여줍니다. 이는 구현 컨트랙트가 향후 업그레이드될 수 있음을 나타내며, 이는 함수 포인터와 관련된 문제를 야기합니다.

function initialize(
        address _rdnt,
        address _weth,
        address _lpMfd,
        address _mfd,
        address _chef,
        address _priceProvider,
        address _eligibilityDataProvider,
        uint256 _hunterShare,
        uint256 _baseBountyUsdTarget,
        uint256 _maxBaseBounty,
        uint256 _bountyBooster
    ) external initializer {
        require(_rdnt != address(0));
        require(_weth != address(0));
        require(_lpMfd != address(0));
        require(_mfd != address(0));
        require(_chef != address(0));
        require(_priceProvider != address(0));
        require(_eligibilityDataProvider != address(0));
        require(_hunterShare <= 10000);
        require(_baseBountyUsdTarget != 0);
        require(_maxBaseBounty != 0);
 
        rdnt = _rdnt;
        weth = _weth;
        lpMfd = _lpMfd;
        mfd = _mfd;
        chef = _chef;
        priceProvider = _priceProvider;
        eligibilityDataProvider = _eligibilityDataProvider;
 
        HUNTER_SHARE = _hunterShare;
        baseBountyUsdTarget = _baseBountyUsdTarget;
        bountyBooster = _bountyBooster;
        maxBaseBounty = _maxBaseBounty;
 
        bounties[1] = getLpMfdBounty;
        bounties[2] = getChefBounty;
        bounties[3] = getAutoCompoundBounty;
        bountyCount = 3;
 
        slippageLimit = 10;
        minDLPBalance = uint256(5).mul(10 ** 18);
 
 
        __Ownable_init();
        __Pausable_init();
    } 

리스팅 3.1: BountyManager.sol

영향 위에서 언급한 세 함수의 오프셋이 변경되면, 함수 포인터가 예상대로 작동하지 않아 컨트랙트의 전체 로직이 변경될 수 있습니다.

제안 컨트랙트는 함수 포인터를 재설정하기 위한 인터페이스를 제공해야 합니다.

3.2 DeFi 보안

3.2.1 잠재적 이슈 2: 오라클의 부적절한 계산

항목 설명
심각도 중간
상태 버전 11에서 수정됨
도입된 버전 버전 1 및 버전 4

설명 컨트랙트 ComboOracle의 함수 consult()는 여러 소스로부터 평균 가격을 계산하는 데 사용됩니다. 버전 1의 구현에서는 산술 평균을 사용하여 최종 가격을 계산하였으며, 이는 소스 오라클 중 하나에 영향을 주어 조작될 수 있습니다.

function consult() public view override returns (uint256 price) {
        require(sources.length != 0);

        uint256 sum;
        for (uint256 i = 0; i < sources.length; i++) {
            uint256 price = sources[i].consult();
            require(price != 0, "source consult failure");
            sum = sum.add(price);
        }
        price = sum.div(sources.length);
    }

리스팅 3.2: ComboOracle.sol

버전 4의 구현에서는 평균 가격이 최저 가격×1.025보다 크면 최저 가격이 반환됩니다. 그러나 소스 오라클 중 하나에서 반환된 결과가 비정상적으로 낮을 경우 반환값은 여전히 조작될 수 있습니다.

/**
    * @notice 계산된 가격
    * @return price 여러 소스의 평균 가격.
    */
   function consult() public view override returns (uint256 price) {
       require(sources.length != 0);

       uint256 sum;
       uint256 lowestPrice;
       for (uint256 i = 0; i < sources.length; i++) {
           uint256 price = sources[i].consult();
           require(price != 0, "source consult failure");
           if (lowestPrice == 0) {
               lowestPrice = price;
           } else {
               lowestPrice = lowestPrice > price ? price : lowestPrice;
           }
           sum = sum.add(price);
       }
       price = sum.div(sources.length);
       price = price > ((lowestPrice * 1025) / 1000) ? lowestPrice : price;
   }

리스팅 3.3: ComboOracle.sol

영향 ComboOracle에서 반환된 가격이 조작될 수 있으며, 이를 통해 공격자가 이익을 취할 수 있습니다.

제안 평균값 대신 중간값을 사용하는 것을 권장합니다. 소스 오라클이 두 개뿐이고 상당히 큰 차이가 발생할 경우, 평균 가격이 최저 가격보다 상당히 클 때 트랜잭션을 되돌리는 것이 더 합리적입니다.

피드백 소스 오라클은 두 개만 있을 것입니다. 상당히 큰 차이가 발생하면 OZ Defender Sentinel을 사용하여 관련 컨트랙트를 일시 중지할 것입니다.

참고 컨트랙트 ComboOracle은 제거되어 더 이상 사용되지 않습니다.

3.2.2 잠재적 이슈 3: BaseBounty를 통한 잠재적 자금 탈취

항목 설명
심각도 높음
상태 버전 4에서 수정됨
도입된 버전 버전 1

설명 사용자는 보상을 얻기 위해 일정 기간 동안 토큰(즉, RDNT)을 잠글 수 있습니다. 잠금이 만료되면, 이 사용자가 자동 재잠금(AutoRelock)을 활성화한 경우 다른 사용자들이 executeBounty() 함수를 호출하여 BaseBounty를 얻기 위해 해당 사용자의 토큰을 재잠금할 수 있습니다. 재잠금 과정에서 만료된 잠금은 내부 함수 _cleanWithdrawableLocks()에서 정리되어 풀에 다시 스테이킹됩니다. 그러나 변수 maxLockWithdrawPerTxn이 한 번에 정리할 수 있는 최대 잠금 수를 제한합니다. 이 경우, executeBounty() 함수가 실행된 후에도 정리되지 않은 만료된 잠금이 남아 있을 수 있습니다. 이로 인해 컨트랙트 MFDPlus의 함수 claimBounty() 106번 줄의 검사를 우회할 수 있습니다. issueBaseBounty가 true로 설정되어 반환될 것입니다.

**
    * @notice 잠금 해제 시간이 지난 모든 잠금 토큰을 인출
    */
   function _cleanWithdrawableLocks(
       address user,
       uint256 totalLock,
       uint256 totalLockWithMultiplier
   ) internal returns (uint256 lockAmount, uint256 lockAmountWithMultiplier) {
       LockedBalance[] storage locks = userLocks[user];

       if (locks.length != 0) {
           uint256 length = locks.length <= maxLockWithdrawPerTxn ? locks.length : maxLockWithdrawPerTxn;
           for (uint256 i = 0; i < length; ) {
               if (locks[i].unlockTime <= block.timestamp) {
                   lockAmount = lockAmount.add(locks[i].amount);
                   lockAmountWithMultiplier = lockAmountWithMultiplier.add(
                       locks[i].amount.mul(locks[i].multiplier)
                   );
                   locks[i] = locks[locks.length - 1];
                   locks.pop();
                   length = length - 1;
               } else {
                   i = i + 1;
               }
           }
           if (locks.length == 0) {
               lockAmount = totalLock;
               lockAmountWithMultiplier = totalLockWithMultiplier;
               delete userLocks[user];

               userlist.removeFromList(user);
           }
       }
   }

리스팅 3.4: MultiFeeDistribution.sol

구체적으로, 공격자는 maxLockWithdrawPerTxn보다 훨씬 많은 횟수로 동일한 만료 시간에 1 wei 토큰을 스테이킹할 수 있습니다. 그 후 공격자는 getLpMfdBounty 액션을 설정하고 executeBounty()를 반복적으로 호출할 수 있습니다. 정리되는 잠금 수가 maxLockWithdrawPerTxn으로 제한되므로, 컨트랙트 BountyManager의 BaseBounty가 공격자에 의해 고갈될 수 있습니다.

영향 공격자는 단일 트랜잭션으로 컨트랙트 BountyManager의 모든 자금을 고갈시켜 설계된 바운티 메커니즘을 방해할 수 있습니다.

제안 함수 _cleanWithdrawableLocks()가 만료된 모든 잠금을 정리할 수 있도록 보장하고, 함수 _stake()에 최소 스테이킹 금액을 설정하십시오.

3.2.3 잠재적 이슈 4: 잠재적으로 유효하지 않은 발행 일정

항목 설명
심각도 낮음
상태 버전 10에서 수정됨
도입된 버전 버전 1

설명 컨트랙트 ChefIncentivesController에서 함수 setEmissionSchedule()은 소유자가 다양한 보상 비율에 대한 일정을 설정하기 위해 호출됩니다. 이 경우 각 일정의 시작 시간(_startTimeOffsets[i] + startTime)은 현재 타임스탬프보다 크도록 검증되어야 합니다. 그러나 _startTimeOffsets의 첫 번째 요소만 검사하며, 이는 충분하지 않습니다. 또한, _startTimeOffsets[i]는 emissionSchedule에 추가될 때 uint256에서 uint128로 변환되는데, 원래 입력이 너무 크면 잘릴 수 있습니다.

function setEmissionSchedule(
        uint256[] calldata _startTimeOffsets,
        uint256[] calldata _rewardsPerSecond
    ) external onlyOwner {
        uint256 length = _startTimeOffsets.length;
        require(length > 0 && length == _rewardsPerSecond.length, "empty or mismatch params");
        if (startTime > 0) {
            require(_startTimeOffsets[0] > block.timestamp.sub(startTime), "invalid start time");
        }
 
        for (uint256 i = 0; i < length; i++) {
            emissionSchedule.push(
                EmissionPoint({
                    startTimeOffset: uint128(_startTimeOffsets[i]),
                    rewardsPerSecond: uint128(_rewardsPerSecond[i])
                })
            );
        }
        emit EmissionScheduleAppended(_startTimeOffsets, _rewardsPerSecond);
    } 

리스팅 3.5: ChefIncentivesController.sol

영향 _startTimeOffsets가 오름차순이 아닌 경우, 약속된 일부 보상이 사용자에게 분배되지 않습니다. _startTimeOffsets[i]가 uint128 범위를 초과하는 경우, 유효하지 않은 발행 일정이 추가됩니다.

제안 _startTimeOffsets가 오름차순인지, 그리고 모든 요소가 uint128 범위 내에 있는지 확인하십시오.

3.2.4 잠재적 이슈 5: 건너뛸 수 있는 발행 일정

항목 설명
심각도 낮음
상태 확인됨
도입된 버전 버전 1

설명 컨트랙트 ChefIncentivesController에서 함수 setScheduleRewardsPerSecond()는 emissionSchedule을 반복하여 이미 시작된 가장 큰 인덱스의 대상 일정을 찾고, 그에 따라 보상 비율을 업데이트합니다. 그러나 이 경우 일부 발행 일정이 건너뛰어질 수 있습니다.

function setScheduledRewardsPerSecond() internal {
		if (!persistRewardsPerSecond) {
			uint256 length = emissionSchedule.length;
			uint256 i = emissionScheduleIndex;
			uint128 offset = uint128(block.timestamp.sub(startTime));
			for (; i < length && offset >= emissionSchedule[i].startTimeOffset; i++) {}
			if (i > emissionScheduleIndex) {
				emissionScheduleIndex = i;
				_massUpdatePools();
				rewardsPerSecond = uint256(emissionSchedule[i - 1].rewardsPerSecond);
			}
		}
	}

리스팅 3.6: ChefIncentivesController.sol

영향 함수 setScheduledRewardsPerSecond()가 오랫동안 호출되지 않으면, 약속된 일부 보상이 사용자에게 분배되지 않을 수 있습니다.

제안 함수 setScheduledRewardsPerSecond()는 함수 claim() 및 _handleActionAfterForToken() 내부에서 호출되므로, 발행 일정이 건너뛰어지는 유일한 경우는 발행 에포크 동안 아무도 프로토콜과 상호작용하지 않는 경우입니다.

3.2.5 잠재적 이슈 6: 마이그레이션 중 변경 가능한 환율

항목 설명
심각도 중간
상태 버전 5에서 수정됨
도입된 버전 버전 1

설명 컨트랙트 Migration은 사용자가 지정된 exchangeRate로 tokenV1에서 tokenV2로 교환할 수 있도록 구현되었습니다. 그러나 마이그레이션 과정 중에 소유자는 함수 setExchangeRate()를 통해 이 exchangeRate를 여전히 조정할 수 있습니다.

/**
    * @notice V1에서 V2로 마이그레이션
    * @param amount V1 토큰의 수량
    */
   function exchange(uint256 amount) external whenNotPaused {
       uint256 v1Decimals = tokenV1.decimals();
       uint256 v2Decimals = tokenV2.decimals();

       uint256 outAmount = amount.mul(1e4).div(exchangeRate).mul(10**v2Decimals).div(10**v1Decimals);
       tokenV1.safeTransferFrom(_msgSender(), address(this), amount);
       tokenV2.safeTransfer(_msgSender(), outAmount);

       emit Migrate(_msgSender(), amount, outAmount);
   }

리스팅 3.7: Migration.sol

영향 마이그레이션 과정 중 exchangeRate가 변경되면 다른 사용자들에게 불공평합니다.

제안 마이그레이션이 시작되면 exchangeRate는 고정되어야 합니다.

3.2.6 잠재적 이슈 7: _transfer()의 부적절한 구현 (I)

항목 설명
심각도 높음
상태 버전 7에서 수정됨
도입된 버전 버전 1

설명 컨트랙트 IncentivizedERC20의 함수 _transfer()는 발신자와 수신자가 동일한 계정(소위 자기 전송)인 상황을 고려하지 않습니다. 구체적으로, 발신자가 수신자와 동일한 경우, 수신자의 잔액을 업데이트할 때 발신자의 잔액이 덮어쓰여집니다. 이 경우 해커는 자신의 계정으로 반복적으로 전송함으로써 무한히 자신의 잔액을 늘릴 수 있습니다.

function _transfer(
        address sender,
        address recipient,
        uint256 amount
      ) internal virtual {
        require(sender != address(0), 'ERC20: transfer from the zero address');
        require(recipient != address(0), 'ERC20: transfer to the zero address');
    
        _beforeTokenTransfer(sender, recipient, amount);
    
        uint256 senderBalance = _balances[sender].sub(amount, 'ERC20: transfer amount exceeds balance');
        uint256 recipientBalance = _balances[recipient].add(amount);
    
        if (address(_getIncentivesController()) != address(0)) {
          // uint256 currentTotalSupply = _totalSupply;
          _getIncentivesController().handleActionBefore(sender);
          if (sender != recipient) {
            _getIncentivesController().handleActionBefore(recipient);
          }
        }
    
        _balances[sender] = senderBalance;
        _balances[recipient] = recipientBalance;
    
        if (address(_getIncentivesController()) != address(0)) {
          uint256 currentTotalSupply = _totalSupply;
          _getIncentivesController().handleActionAfter(sender, senderBalance, currentTotalSupply);
          if (sender != recipient) {
            _getIncentivesController().handleActionAfter(recipient, recipientBalance, currentTotalSupply);
          }
        }
      }

리스팅 3.8: IncentivizedERC20.sol

영향 토큰이 무한히 발행될 수 있습니다.

제안 함수 _transfer()를 올바르게 구현하십시오. 예를 들어, OpenZeppelin의 ERC20 표준 _transfer() 구현을 참고하십시오.

_balances[sender] = _balances[sender].sub(amount, 'ERC20: transfer amount exceeds balance');
_balances[recipient] = _balances[recipient].add(amount);

리스팅 3.9: OpenZeppelin의 ERC20.sol

3.2.7 잠재적 이슈 8: UniV2TwapOracle에서 Period 검사 누락

항목 설명
심각도 낮음
상태 버전 9에서 수정됨
도입된 버전 버전 1

설명 컨트랙트 UniV2TwapOracle에서 속성 _period는 함수 initialize() 및 setPeriod()에서 검증되지 않습니다.

function initialize(
        address _pair,
        address _rdnt,
        address _ethChainlinkFeed,
        uint _period,
        uint _consultLeniency,
        bool _allowStaleConsults
    ) external initializer {
        __Ownable_init();

        pair = IUniswapV2Pair(_pair);
        token0 = pair.token0();
        token1 = pair.token1();
        price0CumulativeLast = pair.price0CumulativeLast(); // 현재 누적 가격값 가져오기 (1 / 0)
        price1CumulativeLast = pair.price1CumulativeLast(); // 현재 누적 가격값 가져오기 (0 / 1)
        uint112 reserve0;
        uint112 reserve1;
        (reserve0, reserve1, blockTimestampLast) = pair.getReserves();
        require(reserve0 != 0 && reserve1 != 0, 'UniswapPairOracle: NO_RESERVES'); // 페어에 유동성이 있는지 확인

        PERIOD = _period;
        CONSULT_LENIENCY = _consultLeniency;
        ALLOW_STALE_CONSULTS = _allowStaleConsults;

        baseInitialize(_rdnt, _ethChainlinkFeed);
    }

    function setPeriod(uint _period) external onlyOwner {
        PERIOD = _period;
    }

리스팅 3.10: UniV2TwapOracle.sol

영향 _period가 너무 작을 경우 오라클이 예상치 못한 값을 반환할 수 있습니다.

제안 함수 initialize 및 setPeriod에서 _period에 최솟값 제한을 설정하십시오.

3.2.8 잠재적 이슈 9: 반환되지 않는 먼지 토큰

항목 설명
심각도 중간
상태 버전 5에서 수정됨
도입된 버전 버전 1

설명 컨트랙트 UniswapPoolHelper에서 함수 zapWETH()는 사용자가 WETH 토큰을 LP 토큰으로 변환하는 것을 돕도록 설계되었습니다. 이 함수는 LP 토큰을 위해 풀에 유동성을 추가하기 위해 함수 addLiquidityWETHOnly()를 호출합니다. 이 과정에서 먼지 토큰이 발생할 수 있으며, 이는 사용자에게 반환되어야 합니다. 그러나 UniswapPoolHelper는 이러한 먼지 토큰을 처리하는 기능을 구현하지 않았습니다.

function zapWETH(uint256 amount)
    public
    returns (uint256 liquidity)
{
    IWETH WETH = IWETH(wethAddr);
    WETH.transferFrom(msg.sender, address(liquidityZap), amount);
    liquidity = liquidityZap.addLiquidityWETHOnly(amount, address(this));
    IERC20 lp = IERC20(lpTokenAddr);
    
    liquidity = lp.balanceOf(address(this));
    lp.safeTransfer(msg.sender, liquidity);
}

리스팅 3.11: UniswapPoolHelper.sol

영향 먼지 토큰이 컨트랙트에 남아 있으며, zapTokens(0,0) 함수를 통해 다른 사람이 이를 추출할 수 있습니다.

제안 유동성 추가 후 먼지 토큰을 반환하는 함수를 구현하십시오.

3.2.9 잠재적 이슈 10: _transfer()의 부적절한 구현 (II)

항목 설명
심각도 중간
상태 버전 9에서 수정됨
도입된 버전 버전 7

설명 컨트랙트 IncentivizedERC20의 함수 _transfer()는 함수 handle_ActionAfter()를 호출하여 컨트랙트 ChefIncentivesController에서 사용자의 상태를 그에 따라 업데이트합니다. 그러나 발신자가 수신자와 동일한 경우 매개변수 senderBalance가 업데이트되지 않아 올바르지 않습니다.

function _transfer(
        address sender,
        address recipient,
        uint256 amount
      ) internal virtual {
        require(sender != address(0), 'ERC20: transfer from the zero address');
        require(recipient != address(0), 'ERC20: transfer to the zero address');
    
        _beforeTokenTransfer(sender, recipient, amount);
    
        uint256 senderBalance = _balances[sender].sub(amount, 'ERC20: transfer amount exceeds balance');
    
        if (address(_getIncentivesController()) != address(0)) {
          // uint256 currentTotalSupply = _totalSupply;
          _getIncentivesController().handleActionBefore(sender);
          if (sender != recipient) {
            _getIncentivesController().handleActionBefore(recipient);
          }
        }
    
        _balances[sender] = senderBalance;
        uint256 recipientBalance = _balances[recipient].add(amount);
        _balances[recipient] = recipientBalance;
    
        if (address(_getIncentivesController()) != address(0)) {
          uint256 currentTotalSupply = _totalSupply;
          _getIncentivesController().handleActionAfter(sender, senderBalance, currentTotalSupply);
          if (sender != recipient) {
            _getIncentivesController().handleActionAfter(recipient, recipientBalance, currentTotalSupply);
          }
        }
      }

리스팅 3.12: IncentivizedERC20.sol

영향 사용자가 자신에게 전송할 때 컨트랙트 ChefIncentivesController에서 상태가 올바르게 업데이트되지 않아, 보상에 추가적인 문제가 발생할 수 있습니다.

제안 함수 handleActionAfter()에서 senderBalance를 수정하십시오.

3.2.10 잠재적 이슈 11: 조작 가능한 컴파운드 보상

항목 설명
심각도 중간
상태 버전 10에서 수정됨
도입된 버전 버전 5

설명 MFDPlus 컨트랙트에서 함수 _convertPendingRewardsToWeth()는 재잠금을 위해 Uniswap 라우터를 통해 사용자의 보상을 WETH로 스왑합니다. 그러나 스왑 후 슬리피지 검사가 없습니다.

IERC20(underlying).safeApprove(uniRouter, removedAmount);
    uint256[] memory amounts = IUniswapV2Router02(uniRouter)
    .swapExactTokensForTokens(
         removedAmount,
         0, // 슬리피지는 이 함수 이후에 처리됨
         mfdHelper.getRewardToBaseRoute(underlying),
         address(this),
         block.timestamp + 10
     );

리스팅 3.13: MFDPlus.sol

영향 공격자가 트랜잭션을 선행 실행하여 가격을 조작하고 이익을 취할 수 있습니다.

제안 함수 claimCompound()에 슬리피지 검사를 추가하십시오.

3.2.11 잠재적 이슈 12: setLeverager()의 접근 제어 부재

항목 설명
심각도 중간
상태 버전 9에서 수정됨
도입된 버전 버전 1

설명 컨트랙트 LendingPool의 함수 setLeverager()에 접근 제어가 없습니다.

uint256[] memory amounts = IUniswapV2Router02(uniRouter)
    .swapExactTokensForTokens(
         removedAmount,
         0, // 슬리피지는 이 함수 이후에 처리됨
         mfdHelper.getRewardToBaseRoute(underlying),
         address(this),
         block.timestamp + 10
     );

리스팅 3.14: LendingPool.sol

영향 leverager가 처음에 설정되지 않은 경우, 공격자가 leverager를 임의의 주소로 설정하여 함수 depositWithAutoDLP()의 로직을 제어할 수 있습니다.

제안 함수 initialize()에서 leverager를 설정하거나 함수 setLeverager()에 접근 제어를 추가하십시오.

3.2.12 잠재적 이슈 13: addLiquidityWETHOnly()의 슬리피지 검사 없음

항목 설명
심각도 중간
상태 확인됨
도입된 버전 버전 1

설명 사용자는 LP 토큰(즉, WETH-RDNT)을 얻기 위해 차입한 WETH 토큰(또는 자신의 ETH 토큰)이나 MFD 컨트랙트의 베스팅 RDNT 토큰을 사용할 수 있습니다.

그러나 풀에 유동성을 추가할 때, 필요한 토큰 계산은 풀의 준비금 양을 기반으로 하며, 이는 조작될 수 있습니다. 이 경우, 사용자가 WETH 토큰만 보유하고 있다면 함수 addLiquidityWETHOnly()가 호출되어 슬리피지를 확인하지 않고 불균형한 풀에서 WETH 토큰의 절반을 RDNT 토큰으로 스왑합니다.

function addLiquidityWETHOnly(uint256 _amount, address payable to)
    public
    returns (uint256 liquidity)
{
    require(to != address(0), "LiquidityZAP: Invalid address");
    uint256 buyAmount = _amount.div(2);
    require(buyAmount > 0, "LiquidityZAP: Insufficient ETH amount");

    (uint256 reserveWeth, uint256 reserveTokens) = getPairReserves();
    uint256 outTokens = UniswapV2Library.getAmountOut(
        buyAmount,
        reserveWeth,
        reserveTokens
    );

    _WETH.transfer(_tokenWETHPair, buyAmount);

    (address token0, address token1) = UniswapV2Library.sortTokens(
        address(_WETH),
        _token
    );
    IUniswapV2Pair(_tokenWETHPair).swap(
        _token == token0 ? outTokens : 0,
        _token == token1 ? outTokens : 0,
        address(this),
        ""
    );

    return _addLiquidity(outTokens, buyAmount, to);
}

리스팅 3.15: LiquidityZap.sol

function getAmountOut(uint amountIn, uint reserveIn, uint reserveOut) internal pure returns (uint amountOut) {
       require(amountIn > 0, 'UniswapV2Library: INSUFFICIENT_INPUT_AMOUNT');
       require(reserveIn > 0 && reserveOut > 0, 'UniswapV2Library: INSUFFICIENT_LIQUIDITY');
       uint amountInWithFee = amountIn.mul(997);
       uint numerator = amountInWithFee.mul(reserveOut);
       uint denominator = reserveIn.mul(1000).add(amountInWithFee);
       amountOut = numerator / denominator;
   }  

리스팅 3.16: UniswapV2Library.sol

영향 공격자가 트랜잭션을 선행 실행하여 가격을 조작하고 이익을 취할 수 있습니다.

제안 함수 addLiquidityWETHOnly()에서 슬리피지를 확인하거나 UniswapPoolHelper에서만 호출될 수 있도록 보장하십시오.

3.2.13 잠재적 이슈 14: loopETH()에서 borrowRatio 검사 누락

항목 설명
심각도 낮음
상태 버전 10에서 수정됨
도입된 버전 버전 1

설명 함수 loopETH()는 레버리지 차입에 사용되며 차입 비율을 지정하는 매개변수 borrowRatio를 받습니다. 그러나 루프가 시작되기 전에 borrowRatio를 검사하지 않습니다.

function loopETH(
        uint256 interestRateMode,
        uint256 borrowRatio,
        uint256 loopCount
    ) external payable {
        uint16 referralCode = 0;
        uint256 amount = msg.value;
        if (IERC20(address(weth)).allowance(address(this), address(lendingPool)) == 0) {
            IERC20(address(weth)).safeApprove(address(lendingPool), type(uint256).max);
        }
        if (IERC20(address(weth)).allowance(address(this), address(treasury)) == 0) {
            IERC20(address(weth)).safeApprove(treasury, type(uint256).max);
        }

        uint256 fee = amount.mul(feePercent).div(RATIO_DIVISOR);
        _safeTransferETH(treasury, fee);
        
        amount = amount.sub(fee);

        weth.deposit{value: amount}();
        lendingPool.deposit(address(weth), amount, msg.sender, referralCode);

        for (uint256 i = 0; i < loopCount; i += 1) {
            amount = amount.mul(borrowRatio).div(RATIO_DIVISOR);
            lendingPool.borrow(address(weth), amount, interestRateMode, referralCode, msg.sender);
            weth.withdraw(amount);

            fee = amount.mul(feePercent).div(RATIO_DIVISOR);
            _safeTransferETH(treasury, fee);

            weth.deposit{value: amount.sub(fee)}();
            lendingPool.deposit(address(weth), amount.sub(fee), msg.sender, referralCode);
        }

        zapWETHWithBorrow(wethToZap(msg.sender), msg.sender);
    }

리스팅 3.17: Leverager.sol

영향 borrowRatio가 RATIO_DIVISOR보다 높을 수 있어 원래 설계와 일치하지 않을 수 있습니다.

제안 borrowRatio가 RATIO_DIVISOR 이하인지 확인하십시오.

3.2.14 잠재적 이슈 15: setPoolIDs()에서 assets와 poolIDs 길이 검사 누락

항목 설명
심각도 낮음
상태 버전 10에서 수정됨
도입된 버전 버전 1

설명 함수 setPoolIDs()는 소유자가 서로 다른 자산에 대해 다른 poolID를 설정할 수 있도록 합니다. 그러나 두 배열의 길이가 같은지 확인하지 않습니다.

// 자산의 풀 ID 설정
    function setPoolIDs(address[] memory assets, uint256[] memory poolIDs) external onlyOwner {
        for (uint256 i = 0; i < assets.length; i += 1) {
            poolIdPerChain[assets[i]] = poolIDs[i];
        }
        emit PoolIDsUpdated(assets, poolIDs);
    } 

리스팅 3.18: StarBorrow.sol

영향 자산이 올바른 poolID에 할당되지 않을 수 있습니다.

제안 assets와 poolIDs의 길이가 같은지 확인하십시오.

3.2.15 잠재적 이슈 16: addBountyContract()에서 mint 권한 취소 누락

항목 설명
심각도 낮음
상태 확인됨
도입된 버전 버전 1

설명 함수 addBountyContract()는 새로운 BountyManager를 설정하는 데 사용됩니다. 그러나 기존 바운티 컨트랙트는 여전히 mint 권한을 보유하고 있어 원래 설계에 반합니다.

function addBountyContract(address _bounty) external onlyOwner {
       BountyManager = _bounty;
       minters[_bounty] = true;
   }

리스팅 3.19: Leverager.sol

영향 더 이상 사용되지 않는 BountyManager가 여전히 mint 권한을 가집니다.

제안 기존 BountyManager 컨트랙트의 mint 권한을 취소하십시오.

피드백 함수 addBountyContract는 BountyManager를 초기화하기 위해 한 번만 호출됩니다.

3.2.16 잠재적 이슈 17: 발행자는 한 번만 지정 가능

항목 설명
심각도 낮음
상태 확인됨
도입된 버전 버전 1

설명 minters는 함수 mint() 및 addReward()에 접근할 수 있는 권한을 가진 사람을 기록하는 데 사용됩니다. 그러나 발행자 중 하나(예: 컨트랙트 ChefIncentivesController)가 업데이트될 때, 구식 발행자를 제거할 수 없습니다.

function setMinters(address[] memory _minters) external onlyOwner {
        require(!mintersAreSet);
        for (uint256 i; i < _minters.length; i++) {
            minters[_minters[i]] = true;
        }
        mintersAreSet = true;
    }

리스팅 3.20: MultiFeeDistribution.sol

영향 구식 발행자는 업그레이드 시 제거할 수 없습니다.

제안 발행자를 수정하는 권한 있는 함수를 구현하십시오.

피드백 BountyManager, ChefIncentivesController 및 MultiFeeDistribution이 업그레이드 가능하므로, 발행자는 항상 동일한 프록시 주소를 유지합니다.

3.3 추가 권고 사항

3.3.1 잠재적 이슈 18: 가스 최적화 (Mfd의 zapVestingToLp())

항목 설명
상태 버전 10에서 수정됨
도입된 버전 버전 1

설명 함수 zapVestingToLp()는 컨트랙트 LockZap에 의해서만 호출되어 사용자의 잠긴 수익을 전달할 수 있습니다. 이 함수는 인덱스 0부터 시작하여 사용자의 earnings 배열을 반복하고 unlockTime이 현재 타임스탬프보다 큰지 확인합니다. 그렇다면 해당 수익은 배열에서 제거되어 전달됩니다. 그러나 배열의 unlockTime은 인덱스와 함께 증가하므로, 배열 끝에서 시작하여 처음으로 반복하는 것이 더 효율적입니다. unlockTime이 현재 타임스탬프보다 작다면 루프를 종료할 수 있습니다.

function zapVestingToLp(address _user)
        external
        override
        returns (uint256 zapped)
    {
        require(msg.sender == lockZap);

        LockedBalance[] storage earnings = userEarnings[_user];
        uint256 length = earnings.length;

        for (uint256 i = 0; i < length; ) {
            // 베스팅만 해당하므로 현재 잠긴 항목만 확인
            if (earnings[i].unlockTime > block.timestamp) {
                zapped = zapped.add(earnings[i].amount);
                // 제거 + 배열 크기 이동
                earnings[i] = earnings[earnings.length - 1];
                earnings.pop();
                length = length.sub(1);
            } else {
                i = i.add(1);
            }
        }

        rdntToken.safeTransfer(lockZap, zapped);

        Balances storage bal = balances[_user];
        bal.earned = bal.earned.sub(zapped);
        bal.total = bal.total.sub(zapped);

        return zapped;
    }

리스팅 3.21: MultiFeeDistribution.sol

제안 earnings 끝에서 시작하여 처음으로 반복하십시오. unlockTime이 현재 타임스탬프보다 작다면 루프를 종료할 수 있습니다.

3.3.2 잠재적 이슈 19: BountyManager의 비어있지 않은 바운티 예비금

항목 설명
상태 버전 10에서 수정됨
도입된 버전 버전 1

설명 함수 _sendBounty()에서, 컨트랙트 BountyManager에 전송할 RDNT 토큰이 충분하지 않으면 이벤트 BountyReseveEmpty()가 발행되고 컨트랙트가 일시 중지됩니다. 그러나 발행된 이벤트와 일치하지 않게 일부 RDNT 토큰이 여전히 남아 있을 수 있습니다.

function _sendBounty(address _to, uint256 _amount)
		internal
		returns (uint256)
	{
		if (_amount == 0) {
			return 0;
		}

		uint256 bountyReserve = IERC20(rdnt).balanceOf(address(this));
		if(_amount > bountyReserve) {
			emit BountyReserveEmpty(bountyReserve);
			_pause();
		} else {
			IERC20(rdnt).safeTransfer(address(mfd), _amount);
			IMFDPlus(mfd).mint(_to, _amount, true);
			return _amount;
		}
	}

리스팅 3.22: BountyManager.sol

제안 충분하지 않더라도 남은 RDNT 토큰을 전송하십시오.

3.3.3 잠재적 이슈 20: requiredUsdValue()의 일관성 없는 명명

항목 설명
상태 확인됨
도입된 버전 버전 1

설명 함수 requiredUsdValue()는 RToken을 보유하여 보상을 얻을 자격이 있는 사용자의 필요 잠금 가치를 확인하는 데 사용됩니다. 계산은 함수 getUserAccountData()에서 반환되는 사용자의 담보 가치를 기반으로 하며, 반환값은 totalCollateralETH로 명명되어 있어 함수 requiredUsdValue()의 totalCollateralUSD와 일치하지 않습니다.

제안 올바른 토큰 이름으로 함수의 명명 규칙을 표준화하십시오. 예를 들어, requiredUsdValue()를 requiredEthValue()로 이름을 변경하십시오.

피드백 AAVE 컨트랙트를 최대한 유사하게 유지하고 싶어 이름을 업데이트하지 않았습니다.

3.4 참고 사항

3.4.1 잠재적 이슈 21: 더 이상 사용되지 않는 MFDPlus

항목 설명
상태 확인됨
도입된 버전 버전 10

설명 컨트랙트 MFDPlus는 더 이상 사용되지 않습니다. 컴파운딩 로직은 컨트랙트 AutoCompounder로 이동되었고 다른 로직은 컨트랙트 MiddleFeeDistribution으로 이동되었습니다.

4. 부록

4.1 자동화 정적 보안 테스트 결과

표 4.1: 자동화 정적 보안 테스트 결과. Found는 도구에서 보고된 이슈 수를 나타냅니다. FP는 수동 검증 후 거짓 양성의 수를 의미합니다.

ID 탐지기 설명 영향 발견 FP 결과
1 arbitrary-send-erc20 임의의 from으로 transferFrom 호출 높음 1 1 통과
2 array-by-reference 값으로 스토리지 배열 수정 높음 0 0 통과
3 incorrect-shift 시프트 명령어에서 매개변수 순서 오류 높음 0 0 통과
4 multiple-constructors 다중 생성자 방식 높음 0 0 통과
5 name-reused 컨트랙트 이름 재사용 높음 0 0 통과
6 protected-vars 접근 제어 없이 변수 직접 수정 높음 0 0 통과
7 rtlo 우에서 좌로 쓰기 제어 문자 사용 높음 0 0 통과
8 shadowing-state 상태 변수 섀도잉 높음 1 1 통과
9 suicidal 누구나 컨트랙트를 소멸시킬 수 있는 함수 높음 0 0 통과
10 uninitialized-state 초기화되지 않은 상태 변수 높음 3 3 통과
11 uninitialized-storage 초기화되지 않은 스토리지 변수 높음 0 0 통과
12 unprotected-upgrade 보호되지 않은 업그레이드 가능 컨트랙트 높음 1 1 통과
13 arbitrary-send-erc20-permit transferFrom이 permit으로 임의의 from 사용 높음 0 0 통과
14 arbitrary-send-eth 임의의 목적지로 이더를 전송하는 함수 높음 0 0 통과
15 controlled-array-length 오염된 배열 길이 할당 높음 0 0 통과
16 controlled-delegatecall 제어된 delegatecall 목적지 높음 0 0 통과
17 delegatecall-loop 루프 내에서 delegatecall을 사용하는 payable 함수 높음 0 0 통과
18 msg-value-loop 루프 내에서 msg.value 사용 높음 0 0 통과
19 reentrancy-eth 재진입 취약점 (이더 탈취) 높음 5 5 통과
20 storage-array 부호 있는 스토리지 정수 배열 컴파일러 버그 높음 0 0 통과
21 unchecked-transfer 확인되지 않은 토큰 전송 높음 12 12 통과
22 weak-prng 취약한 PRNG 높음 0 0 통과
23 domain-separator-collision EIP-2612의 DOMAIN_SEPARATOR()와 서명이 충돌하는 함수를 가진 ERC20 토큰 탐지 중간 0 0 통과
24 enum-conversion 위험한 열거형 변환 탐지 중간 0 0 통과
25 erc20-interface 올바르지 않은 ERC20 인터페이스 중간 0 0 통과
26 erc721-interface 올바르지 않은 ERC721 인터페이스 중간 0 0 통과
27 incorrect-equality 위험한 엄격한 동등성 중간 23 23 통과
28 locked-ether 이더를 잠그는 컨트랙트 중간 1 1 통과
29 mapping-deletion 구조체를 포함한 매핑 삭제 중간 0 0 통과
30 shadowing-abstract 추상 컨트랙트에서 상태 변수 섀도잉 중간 0 0 통과
31 tautology 동어반복 또는 모순 중간 0 0 통과
32 write-after-write 사용되지 않는 쓰기 중간 3 3 통과
33 boolean-cst 불리언 상수 오용 중간 0 0 통과
34 constant-function-asm 어셈블리 코드를 사용하는 상수 함수 중간 0 0 통과
35 constant-function-state 상태를 변경하는 상수 함수 중간 0 0 통과
36 divide-before-multiply 부정확한 산술 연산 순서 중간 20 20 통과
37 reentrancy-no-eth 재진입 취약점 (이더 탈취 없음) 중간 12 12 통과
38 reused-constructor 재사용된 기본 생성자 중간 0 0 통과
39 tx-origin tx.origin의 위험한 사용 중간 1 1 통과
40 unchecked-lowlevel 확인되지 않은 저수준 호출 중간 0 0 통과
41 unchecked-send 확인되지 않은 전송 중간 0 0 통과
42 uninitialized-local 초기화되지 않은 지역 변수 중간 33 33 통과
43 unused-return 사용되지 않는 반환값 중간 19 19 통과

4.2 자동화 동적 보안 테스트 결과

표 4.2: 대출 관련 로직에 대한 테스트된 속성

ID 속성 결과
1 deposit 호출은 onBehalfOf의 RToken 수량 감소로 이어지지 않음 통과
2 withdraw 호출은 msg.sender의 RToken 수량 증가로 이어지지 않음 통과
3 안정 이자율 모드로 borrow 호출은 onBehalfOf의 StableDebtToken 감소로 이어지지 않음. 통과
4 변동 이자율 모드로 borrow 호출은 onBehalfOf의 VariableDebtToken 감소로 이어지지 않음. 통과
5 msg.sender와 다른 onBehalfOf로 borrow 호출은 msg.sender의 차입 허용량 증가로 이어지지 않음. 통과
6 안정 이자율 모드로 repay 호출은 onBehalfOf의 StableDebtToken 증가로 이어지지 않음. 통과
7 변동 이자율 모드로 repay 호출은 onBehalfOf의 VariableDebtToken 증가로 이어지지 않음. 통과
8 liquidityIndex는 절대 감소하지 않음. 통과
9 liquidityIndex는 동일 블록 내에서 일정하게 유지됨. 통과
10 variableBorrowIndex는 절대 감소하지 않음. 통과
11 variableBorrowIndex는 동일 블록 내에서 일정하게 유지됨. 통과
12 담보 금액 감소는 건강 계수가 1 미만이 되지 않음. 통과
13 차입 금액 증가는 건강 계수가 1 미만이 되지 않음. 통과

표 4.3: 스테이킹 관련 로직에 대한 테스트된 속성

ID 속성 결과
1 사용자의 총 잔액은 항상 잠긴 잔액, 잠금 해제된 잔액 및 수익 잔액의 합과 같음. 통과
2 사용자의 잠긴 잔액은 항상 userLocks 수량의 합과 같음 통과
3 사용자의 lockedWithMultiplier 잔액은 항상 userLocks 수량 곱하기 userLocks 배수의 합과 같음 통과
4 lockedSupply는 항상 사용자들의 잠긴 잔액의 합과 같음 통과
5 lockedSupplyWithMultiplier는 항상 사용자들의 lockedWithMultiplier 잔액의 합과 같음 통과
6 rewardPerTokenStored는 절대 감소하지 않음. 통과
7 rewardPerTokenStored는 동일 블록 내에서 일정하게 유지됨. 통과
8 totalSupply는 항상 사용자들의 수량의 합과 같음 통과
9 accRewardPerShare는 절대 감소하지 않음. 통과
10 accRewardPerShare는 동일 블록 내에서 일정하게 유지됨. 통과

표 4.4: 기타 기능에 대한 테스트된 속성

ID 속성 결과
1 컨트랙트 LockedZap의 WETH 및 RDNT 잔액은 항상 0임. 통과
2 컨트랙트 LiquidityZap의 WETH 및 RDNT 잔액은 항상 0임. 통과
3 컨트랙트 BalancerPoolHelper의 WETH 및 RDNT 잔액은 항상 0임. 통과
4 컨트랙트 UniswapPoolHelper의 WETH 및 RDNT 잔액은 항상 0임. 통과
5 loop 호출은 항상 사용자가 보상을 받을 자격이 있게 함 통과
6 loopETH 호출은 항상 사용자가 보상을 받을 자격이 있게 함 통과
7 _execute가 false인 executeBounty 호출은 스토리지 변경으로 이어지지 않음. 통과
8 발신자와 수신자가 동일한 transfer 호출은 잔액 변경으로 이어지지 않음. 버전 1에서 실패. 버전 7에서 통과

5 공지 및 비고

5.1 면책 조항

본 보고서는 투자 조언이나 개인 추천을 구성하지 않습니다. 본 보고서는 토큰, 토큰 판매 또는 기타 제품, 서비스 또는 기타 자산의 잠재적 경제성을 고려하지 않으며, 이를 고려하거나 관련이 있는 것으로 해석되어서는 안 됩니다. 어떠한 기업도 토큰, 제품, 서비스 또는 기타 자산을 매수하거나 매도하는 결정을 내리는 목적을 포함하여 어떠한 방식으로든 본 보고서에 의존해서는 안 됩니다.

본 보고서는 특정 프로젝트나 팀에 대한 보증이 아니며, 본 보고서는 특정 프로젝트의 보안을 보장하지 않습니다. 본 보안 테스트는 스마트 컨트랙트의 모든 보안 이슈를 발견한다는 어떠한 보증도 제공하지 않습니다. 즉, 평가 결과는 추가적인 보안 이슈 발견이 없음을 보장하지 않습니다. 보안 테스트가 포괄적으로 간주될 수 없으므로, 스마트 컨트랙트의 보안을 보장하기 위해 독립적인 감사 및 공개 버그 바운티 프로그램을 진행할 것을 항상 권고합니다.

본 보안 테스트의 범위는 섹션 1.2에 언급된 코드로 제한됩니다. 명시적으로 지정되지 않는 한, 언어 자체의 보안(예: Solidity 언어), 기반 컴파일 도구 체인 및 컴퓨팅 인프라는 범위에 포함되지 않습니다.

5.2 감사 절차

저희는 다음 절차에 따라 감사를 수행합니다.

  • 취약점 탐지 먼저 자동 코드 분석기로 스마트 컨트랙트를 스캔한 다음, 보고된 이슈를 수동으로 검증(거부 또는 확인)합니다.

  • 시맨틱 분석 스마트 컨트랙트의 비즈니스 로직을 연구하고, 자동 퍼징 도구(저희 연구팀이 개발한)를 사용하여 가능한 취약점에 대해 추가 조사를 수행합니다. 또한 결과를 교차 검증하기 위해 독립적인 감사자들과 함께 가능한 공격 시나리오를 수동으로 분석합니다.

  • 권고 사항 가스 최적화, 코드 스타일 등을 포함한 좋은 프로그래밍 관행의 관점에서 개발자에게 유용한 조언을 제공합니다.

주요 구체적인 검사 항목은 다음과 같습니다.

5.2.1 소프트웨어 보안

  • 재진입

  • DoS

  • 접근 제어

  • 데이터 처리 및 데이터 흐름

  • 예외 처리

  • 신뢰되지 않는 외부 호출 및 제어 흐름

  • 초기화 일관성

  • 이벤트 운영

  • 오류가 발생하기 쉬운 무작위성

  • 프록시 시스템의 부적절한 사용

5.2.2 DeFi 보안

  • 시맨틱 일관성

  • 기능 일관성

  • 권한 관리

  • 비즈니스 로직

  • 토큰 운영

  • 비상 메커니즘

  • 오라클 보안

  • 화이트리스트 및 블랙리스트

  • 경제적 영향

  • 일괄 전송

5.2.3 NFT 보안

  • 중복 항목

  • 토큰 수신자 검증

  • 오프체인 메타데이터 보안

5.2.4 추가 권고 사항

  • 가스 최적화

  • 코드 품질 및 스타일

참고: 앞서 언급한 검사 항목은 주요 항목입니다. 프로젝트의 기능에 따라 감사 과정에서 더 많은 검사 항목을 사용할 수 있습니다.

Sign up for the latest updates
~$410만 손실: Taiko, SecondFi 익스플로잇 | BlockSec 위클리
Security Insights

~$410만 손실: Taiko, SecondFi 익스플로잇 | BlockSec 위클리

이 주간 블록체인 보안 리포트는 2026년 6월 22~28일 발생한 주요 사건 2건을 다루며, 이더리움과 카르다노에서 약 410만 달러의 피해가 확인됐습니다. Taiko 브릿지 공격은 노출된 SGX 서명 키와 디버그 엔클레이브를 거부하지 못한 증명 정책 결함을 이용해 악성 증명자를 등록하고 L2 상태 증명을 위조했습니다. SecondFi 지갑은 Ed25519 논스 도출 시 비밀 입력이 제거되는 결함으로 공개 트랜잭션 데이터만으로 개인 키 복구가 가능했습니다.

~$18M 손실: jaredFromSubway, Aztec 등 | BlockSec 위클리
Security Insights

~$18M 손실: jaredFromSubway, Aztec 등 | BlockSec 위클리

이 주간 블록체인 보안 보고서는 2026년 6월 15일~21일을 다루며, 이더리움과 BNB 체인에서 3건의 주요 사고가 발생해 약 $18.3M의 손실이 발생했습니다. jaredFromSubway 사건은 MEV 봇이 차익거래를 위해 신뢰할 수 없는 제3자 컨트랙트에 자산을 승인한 역방향 승인 공격으로, 가짜 래퍼 토큰과 스왑 풀을 이용해 약 $15M 손실이 발생했습니다. Aztec은 이스케이프 해치 ZK 회로의 제약 누락으로 공격자가 가짜 머클 트리로 온체인 검증을 통과했습니다.

Web3 컴패니언: 오픈소스 보안 에이전틱 지갑

Web3 컴패니언: 오픈소스 보안 에이전틱 지갑

BlockSec가 Web3 Companion을 오픈소스로 공개했습니다. 이 보안 중심의 에이전트 지갑은 자체 AI 에이전트를 신뢰하지 않는 방식으로 설계되었으며, 키 격리, 강력한 정책, Passkey를 활용해 온체인 자산을 보호합니다.

Best Security Auditor for Web3

Validate design, code, and business logic before launch. Aligned with the highest industry security standards.

BlockSec Audit