해당 내용을 간단히 요약하면 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)에 취약점이 있다.
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를 다음과 같이 작성했다.
해당 공격을 수행하면 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에 대한 접근을 차단했다.
위 코드는 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일 경우 에러를 반환하도록 하고 있다.