Official write-up by Cyber League

Cyber League Season 1, Major 1: Space Contract Official Write-up

N0H4TS
7 min readMay 30, 2022

By Cyber League (Div0-N0H4TS)

Challenge Details

Name: Space Contract
Category: Cryptography

Challenge Description

Our motherland have engaged a vendor to develop smart contract embedded in our fuel tank. We wanna make sure that the amount of fuel is recorded in an immutable blockchain. (In russian) Chert by pobral etikh idiotov! The smart contract is broken and our funds are stolen. Investigate and find out the culprite immediately!

A smart contract link and a script were provided to the participants.
https://kovan.etherscan.io/address/0x16537776395108789FE5cC5420545CAb210a7D30

check_txn.py script

import hashlibtxs = [
# put the attack transactions your found here
]
'''
e.g.,
txs = [
'0x0000000000000000000000000000000000000000000000000000000000000000',
'0x0000000000000000000000000000000000000000000000000000000000000001',
'0x0000000000000000000000000000000000000000000000000000000000000002',
...
]
'''
assert len(txs) == 22
salt = b'hint: find abnormal transactions'
m = hashlib.sha256()
for tx_hash in sorted(txs):
assert len(tx_hash) == 66 and tx_hash[:2] == '0x'
m.update(salt + tx_hash.encode() + m.digest())
assert m.hexdigest()[:16] == 'bf22a2d63563554c'
print('flag{' + m.hexdigest() + '}')

Process of solving the Challenge

An Easter egg was hidden on the CTF platform. A suspicious image was spotted on the front page of the platform!

Upon investigation it seems the image holds the real message.

The message was:

Our motherland have engaged a vendor to develop smart contract embedded in our fuel tank.We wanna make sure that the amount of fuel is recorded in an immutable blockchain. (In russian) Chert by pobral etikh idiotov! The smart contract is broken and our funds are stolen. Investigate and find out the culprit immediately! Smart contract link: https://kovan.etherscan.io/address/0x16537776395108789FE5cC5420545CAb210a7D30

The real challenge starts now!

Step 0: Prerequisites

This challenge requires basic knowledge of the Ethereum blockchain (e.g., accounts, transactions) and smart contracts, including ERC20 tokens and the widely-used OpenZeppelin libraries. If you are not familiar with them, please refer to the following links:

Step 1: Gathering Basic Information

According to the challenge description, our goal is to find all transactions that exploit a vulnerability in the lending market that allows unexpected or unauthorized funds to be transferred from it. To examine the transactions on the Ethereum blockchain (the Kovan testnet), I’d recommend using the Etherscan blockchain explorer. Ethersacn allows you to, for example,

  1. View all transactions included in a specific block
  2. View all details (e.g., sender, receiver, data) of a specific transaction
  3. View a contract’s source code (if verified) and ABI.
  4. Read public state variables or call view functions on the contract easily.

From Etherscan, we can get the source code of the Market and the Oracle contract and get a sense of how users (or attackers) interact with the Market contract. We may see a bunch of transactions calling, e.g., lock, unlock, borrow, repay, which gives us a hint on how the market worked. We can guess that the vulnerability is likely something that allows the attacker to gain more tokens than he should.

Step 2: Spotting the Vulnerability

What’s the vulnerability in the code? Let’s see how the Market contract fetches a token's price:

function getTokenPrice(address token) internal view returns (uint) {
if (token == address(0) || token == WETH) return ONE;
(uint price, ) = IOracle(oracle).getTokenPrice(token);
return price;
}

And, in the Oracle contract, a token's price information is read directly from the contract storage mapping:

function getTokenPrice(address token) public view returns (uint, uint) {
return (priceInfo[token].priceToETH, priceInfo[token].lastUpdate);
}

The vulnerability is that there are no sanity checks on the returned price value neither in the Oracle or the Market contract. If the price of a token is not set in the oracle, Oracle.getTokenPrice() always returns (0, 0) (the default value in a storage slot). The Market contract does not ensure lastUpdate > 0 either, but it directly uses the value returned from the oracle. Therefore, Market.getTokenPrice() returns 0 for any token whose price is not set yet.

By examining the transactions sent to the Market contract, we may notice that after the owner created the market, he initialized the allowed locked and borrowed tokens for the market. Further investigation shows that the DAI and USDT tokens were set to be the allowed borrowed tokens. However, by examining the transactions sent to the oracle, we notice that the owner never set the price of USDT. Therefore, the market thought the price of USDT was 0 and allowed the attackers to borrow any amount of USDT without providing any collateral tokens. This was the attack that stole the USDT from the market.

Step 3: Finding Attack Transactions

To borrow USDT from the market, the attacker had to:

  1. Create a position with the borrowed token being USDT (the locked token can be any token)
  2. Borrow some amount of USDT from the market using the corresponding position

Any transaction that either performed the first step or the second step is considered an attack transaction, and we wanted to find all of them. There were about 172 transactions sent to the market, so it would be better to automate examining and filtering out the attack transactions. Let’s use the Web3.py library to assist us. First, to fetch the data on the Ethereum blockchain, we need to connect to an RPC node. Here, I use the Alchemy service as an example:

def setup_web3_provider(timeout=120):
env = 'ALCHEMY_KOVAN_API_KEY'
end_point = f'https://eth-kovan.alchemyapi.io/v2/{os.environ[env]}'
w3 = Web3(Web3.HTTPProvider(
endpoint_uri=end_point,
request_kwargs={
'timeout': timeout
}
))
return w3

Next, we iterate the blocks during the attack period to get all transactions within them. Please refer to the above link for the detailed usage of Web3.py APIs:

for block_num in range(start_block_num, end_block_num + 1):
block = w3.eth.get_block(block_num)
for tx_hash in block['transactions']:
tx = w3.eth.get_transaction(tx_hash)
if tx['to'] == market:
print(tx['from']) # the sender of the tx

We may notice that only three users interacted with the market during this period. Let’s call them Alice, Bob, and Cathy. We filter out all successful transactions sent from any of them and check whether they were the attack transactions:

for block_num in range(start_block_num, end_block_num + 1):
block = w3.eth.get_block(block_num)
for tx_hash in block['transactions']:
tx = w3.eth.get_transaction(tx_hash)
if tx['from'] in [alice, bob, cathy] and tx['to'] == market:
tx_receipt = w3.eth.get_transaction_receipt(tx_hash)
if tx_receipt['status'] == 1: # success
... # check this is an attack or not

To know which function the transaction was calling, we need to get the contract ABI (available on Etherscan). Then, we create a Contract object representing the market:

with open('abi.json', 'r') as f:
obj = json.load(f)
abi = json.loads(obj['result'])
cont = w3.eth.contract(address=market, abi=abi)

With the contract object, we can decode the transaction input to get the calling function and the provided arguments, which we can use to identify transactions calling the createPosition and borrow functions. One more thing, because the newly created position's ID is logged in a smart contract event, we have to get it by processing the transaction receipt:

func, args = cont.decode_function_input(tx['input'])
if func.fn_name == 'createPosition' and args['borrowedToken'] == usdt:
print('found attack, createPosition')
pid = cont.events.CreatePosition().processReceipt(tx_receipt)[0]['args']['pid']
...

Putting them all together, we have a script to find all attack transactions. Please see the solution.py file for the complete script.

attack_tx_hashes, attack_pids = set(), set()
for block_num in range(start_block_num, end_block_num + 1):
print('block # = ', block_num)
block = w3.eth.get_block(block_num)
for tx_hash in block['transactions']:
tx = w3.eth.get_transaction(tx_hash)
if tx['from'] in [alice, bob, cathy] and tx['to'] == market:
tx_receipt = w3.eth.get_transaction_receipt(tx_hash)
if tx_receipt['status'] == 1: # success
func, args = cont.decode_function_input(tx['input'])
if func.fn_name == 'createPosition' and args['borrowedToken'] == usdt:
print('found attack, createPosition')
pid = cont.events.CreatePosition().processReceipt(tx_receipt)[0]['args']['pid']
attack_tx_hashes.add(tx_hash)
attack_pids.add((tx['from'], pid))
if func.fn_name == 'borrow' and (tx['from'], args['pid']) in attack_pids:
print('found attack, borrow')
attack_tx_hashes.add(tx_hash)
time.sleep(0.5)

solution.py

import os
import json
import time
from web3 import Web3
def setup_web3_provider(timeout=120):
os.environ['ALCHEMY_KOVAN_API_KEY'] = ''
env = 'ALCHEMY_KOVAN_API_KEY'
end_point = f'https://eth-kovan.alchemyapi.io/v2/{os.environ[env]}'
w3 = Web3(Web3.HTTPProvider(
endpoint_uri=end_point,
request_kwargs={
'timeout': timeout
}
))
return w3
owner = '0x85BFba62f1b0bb5ad63Dc15611B96A3457095390'
alice = '0x488daD0ce94f34e33069b8Ae5E16826b63f0F575'
bob = '0xB09c603eA024b4435D74db07d1f728A80E6e36aE'
cathy = '0x941c73fBB405a9024C08A52cBEeAF80D02d2B1A1'
market = '0x16537776395108789FE5cC5420545CAb210a7D30'
usdt = '0x13512979ADE267AB5100878E2e0f485B568328a4'
blocks = [30460914, 30460923, 30460928, 30461363, 30461371, 30461372, 30461374, 30461377, 30461378, 30461414, 30461416, 30461418, 30461419, 30461538, 30461582, 30461660, 30461710, 30461716, 30461747, 30461788, 30462076, 30462090, 30462097, 30462120, 30462159, 30462170, 30462178, 30462181, 30462184, 30462186, 30462205, 30462216, 30462222, 30462233, 30462235, 30462272, 30462275, 30462290, 30462292, 30462297, 30462299, 30462308, 30462312, 30462322, 30462330, 30462334, 30462336, 30462340, 30462389, 30462400, 30462408, 30462417, 30462423, 30462430, 30462441, 30462467, 30462475, 30462480, 30462484, 30462492, 30462510, 30462513, 30462520, 30462530, 30462537, 30462550, 30462560, 30462573, 30462575, 30462581, 30462592, 30462594, 30462598, 30462613, 30462622, 30462632, 30462633, 30462646, 30462657, 30462672, 30462687, 30462692, 30462694, 30462701, 30462708, 30462710, 30462712, 30462724, 30462725, 30462734, 30462753, 30462770, 30462776, 30462784, 30462790, 30462810, 30462823, 30462832, 30462836, 30462854, 30462871, 30462880, 30462891, 30462903, 30462906, 30462929, 30462930, 30462944, 30462957, 30462959, 30462975, 30462981, 30462998, 30463008, 30463019, 30463038, 30463040, 30463060, 30463077, 30463083, 30463105, 30463111, 30463120, 30463124, 30463134, 30463140, 30463149, 30463175, 30463184, 30463192, 30463208, 30463213, 30463249, 30463261, 30463263, 30463268, 30463277, 30463281, 30463292, 30463294, 30463308, 30463309, 30463324, 30463338, 30463340, 30463344, 30463356, 30463359, 30463364, 30463372, 30463374, 30463377, 30463384, 30463400, 30463403, 30463414, 30463426, 30463428, 30463446, 30463453, 30463475, 30463486, 30463513, 30463519, 30463532, 30463539, 30463543, 30463547, 30463550, 30463555, 30463561, 30463563]with open('abi.json', 'r') as f:
obj = json.load(f)
abi = json.loads(obj['result'])
w3 = setup_web3_provider()
cont = w3.eth.contract(address=market, abi=abi)
attack_tx_hashes, attack_pids = set(), set()
for block_num in blocks:
print('block # = ', block_num)
block = w3.eth.get_block(block_num)
for tx_hash in block['transactions']:
tx = w3.eth.get_transaction(tx_hash)
if tx['from'] in [alice, bob, cathy] and tx['to'] == market:
tx_receipt = w3.eth.get_transaction_receipt(tx_hash)
if tx_receipt['status'] == 1: # success
func, args = cont.decode_function_input(tx['input'])
if func.fn_name == 'createPosition' and args['borrowedToken'] == usdt:
print('found attack, createPosition')
pid = cont.events.CreatePosition().processReceipt(tx_receipt)[0]['args']['pid']
attack_tx_hashes.add(tx_hash)
attack_pids.add((tx['from'], pid))
if func.fn_name == 'borrow' and (tx['from'], args['pid']) in attack_pids:
print('found attack, borrow')
attack_tx_hashes.add(tx_hash)
time.sleep(0.5)
print(attack_tx_hashes)

Step 4: Getting the flag

By pasting the found transactions in the check_txn.py script, we get the flag.

flag{bf22a2d63563554c2073f9480867794e17297ce17c7ec4cc3502979828e4253f}

Change the “flag” to “CYBERLEAGUE” for flag submission.

CYBERLEAGUE{bf22a2d63563554c2073f9480867794e17297ce17c7ec4cc3502979828e4253f}

--

--

N0H4TS
N0H4TS

Written by N0H4TS

Start as an Apprentice, and become a Master.

No responses yet