Official write-up by Cyber League
Cyber League Season 1, Major 1: Space Contract Official Write-up
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:
- ethereum.org — Community guides and resources
- ethereum.org — ERC-20 TOKEN STANDARD
- OpenZeppelin/openzeppelin-contracts
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,
- View all transactions included in a specific block
- View all details (e.g., sender, receiver, data) of a specific transaction
- View a contract’s source code (if verified) and ABI.
- 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:
- Create a position with the borrowed token being USDT (the locked token can be any token)
- 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 Web3def 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 w3owner = '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}