Write-up by Cyber League 2022 Participants
Cyber League Season 1, Major 1: Space Contract Write-up
Challenge Details
Name: Space Contract
Category: Smart Contract
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 was provided to the participants.
https://kovan.etherscan.io/address/0x16537776395108789FE5cC5420545CAb210a7D30
Process of solving the Challenge
1. Finding vulnerability in the contract
In the validation script that was given to us, we can tell that we need 22 transaction hash based on “len(txs) == 22”. The exact hashes to be found needs to be based on the hint given, which is to “find abnormal transactions”.
First we take a look at Set Token Price transactions at here.
We noticed that there are 4 types of tokens involved:
DAI (0xFf795577d9AC8bD7D90Ee22b6C1703490b6512FD)
ChainLink (0xAD5ce863aE3E4E9394Ab43d4ba0D80f419F61789)
USTD (0x13512979ADE267AB5100878E2e0f485B568328a4)
WBTC (0xD1B98B6607330172f1D991521145A22BCe793277)
However, based on further inspections, all the tokens had its price set except for USTD.
Take for example, here sets the token price for DAI token, as seen from the input data.
The same can be found for ChainLink token and WBTC token as well, but set token price for USTD is missing. This gives us a clearer idea on how we can find the attack transactions required.
2. Closer look at the vulnerability
Since we now know that oracle machine did not set USTD token price, when an user decide to borrow, they can exploit the vulnerability by bypassing the getHealthFactor function, allowing unlimited and malicious borrowing of USTD.
function getTokenPrice(address token) internal view returns (uint) {
if (token == address(0) || token == WETH) return ONE;
(uint price, ) = IOracle(oracle).getTokenPrice(token);
return price;
}
As we can see from the getTokenPrice function, the price is determined by the type of token passed in.
function getHealthFactor(Position storage position) internal view returns (uint) {
uint lockedTokenPrice = getTokenPrice(position.lockedToken);
uint borrowedTokenPrice = getTokenPrice(position.borrowedToken);
uint scaledLocked = position.locked * 10 ** (18 - tokenDecimals[position.lockedToken]);
uint scaledBorrowed = position.borrowed * 10 ** (18 - tokenDecimals[position.borrowedToken]);
uint lockedValue = scaledLocked * lockedTokenPrice / ONE;
uint borrowedValue = scaledBorrowed * borrowedTokenPrice / ONE;
if (borrowedValue == 0) return ONE;
uint healthFactor = lockedValue * liquidationThreshold * ONE / (borrowedValue * 10000);
return healthFactor;
}
Since price is never set for USTD, borrowedValue will always be 0 if the token is USTD. This satisfies the condition “if (borrowedValue == 0)”, causing returned value to always be ONE.
function isPositionSafe(Position storage position) internal view returns (bool) {
uint healthFactor = getHealthFactor(position);
return healthFactor >= ONE;
}
The returned value from getHealthFactor function will be ONE, hence passing the check function isPositionSafe.
function unlock(uint pid, uint amount) public whenNotPaused {
require(pid < positions[msg.sender].length, "InvalidPID");
Position storage position = positions[msg.sender][pid];
position.locked -= amount;
sendToken(position.lockedToken, amount);
require(isPositionSafe(position), "UnsafePosition");
emit Unlock(msg.sender, pid, position.lockedToken, amount);
}
function borrow(uint pid, uint amount) public whenNotPaused {
require(pid < positions[msg.sender].length, "InvalidPID");
Position storage position = positions[msg.sender][pid];
position.borrowed += amount;
sendToken(position.borrowedToken, amount);
require(isPositionSafe(position), "UnsafePosition");
emit Borrow(msg.sender, pid, position.borrowedToken, amount);
}
One original concern was that there could be exploit at the unlock function since it uses isPositionSafe for security. However, unlock function only takes out what is previously locked, but since you can not exceed a certain fixed amount, it does not affect too much.
On the other hand, borrow function can always be passed as long as the token is USTD, allowing for malicious transfer of funds. We also have to take note that before borrowing, createPosition function is called so we will need to include it in our search as well.
Thus we come to the conclusion that the attack transactions we are looking for are the ones that involves USTD tokens. These transactions include any borrow or createPosition transactions which leads to the transfer of USTD tokens.
All we need to do now is to look through all the borrow and createPosition transactions that occurred before the suspicious borrowing and note down the hash values.
3. Final script with hashes
import hashlib
txs = [
# borrow
'0x6705735d1d4c526cd5db6c5810de6b11ba196fb93715a67ae855d037bfeaeaec',
'0x657b24138cea98eca019de351d60c69210334926c46d9bfb7a59f5c0db5d16f4',
'0xdb3f9da9cc6600ba9c2ca0685cd5c29818dae632fb3be65d530ef404b3ade202',
'0x6b52684eda64076701e647911f55019fb18b30d980554dd2df5ee0e777506c3a',
'0x95bc3879debc5ffbe9932d5a60dd53146374e7dd553fef5f00152371bbb75f38',
'0xf8cb1d747b53bbd4b4346eb8522fa3df36025dafedeb5b19bf54a9fff946ae8a',
'0x99a5722bb73a73c6b47967f9b457e888d6503b0f9e1bf23fbf36de56ebad1522',
'0x8beb80929a026d68fe9e80d0e46dadf43f9b8c68dc1db7e53b996e9654a3c71c',
'0x13240bc2ce9333db092704b057083d23fa4e365b1b049ea839eb9955591ffd4d',
'0x3a109f0754113742eaae4bad747261aa7d9a1a9e2fb4d12704631b333b790006',
'0x8b0b5a0d65a1272d811f1db90cf7a24e43c64cf0d4767a78accf0dd9afce954c',
'0xdd588f3c2a9f25aa57d27e3257fe93882cf211470bd92e56b5271f95ab3c955f',
'0x6287be53eb87e475cfdabfe85c7db800c5262a469a4c270e55b8ddf481b6dae3',
'0x6ce3f133f0d925b125b6d8861b582cfd3b9abc8df4ce6ecd9607751b5fa6e796',
'0xcfb692d772f8acb90bc14a5da06f72c8ed9d871bbe76787ac0fa8a40e1ef11aa',
'0x0635eeabe77d53672c227c0938f73f43c8f43b984e2c02d4e4a7b4e4d9740a09',
'0xea24903d0a72b56457b88bdfd842f4065b0371b9c118dae08aca8dadb43c81b4',
# create position
'0xf7a6068687cfd85a24c8fd169c3c95133ff7a957c843171733902925990b4b74',
'0xef40506ae849c17dfbb75f97331860750217602f9c0a9c7718e50d04f0e233b8',
'0xcc8ff167cc6a1014f5c4b7445f26b17f68cc95bcc0c578c5330278dda8229d0b',
'0xc20e2ac5792a3350febd9a7e62527faccf4e07de2c1454572380e6f629ecca18',
'0xb1f54b9969ba60075775a3168d2ad16482e672cbf386ba4f9d6f433fe9d86fbe'
]
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())
print(m.hexdigest())
assert m.hexdigest()[:16] == 'bf22a2d63563554c'
print('flag{' + m.hexdigest() + '}')
Flag in submission format:
CYBERLEAGUE{bf22a2d63563554c2073f9480867794e17297ce17c7ec4cc3502979828e4253f}