Recently, there was an airdrop for Optimism, which I managed to qualify for some allocation. Although the sum was small, it was still something.
However, the allocation was given to my compromised wallet.
Airdrops and MerkleDistributor.sol
Fortunately, the airdrop mechanism was done with a verified contract, so I could view the source code. Taking a look at the contract, we see that the entrypoint is a MerkleDistributor
contract. The interface is specified below,
// see the rest of the interface here https://github.com/Uniswap/merkle-distributor/blob/master/contracts/interfaces/IMerkleDistributor.sol
interface IMerkleDistributor {
// the only functions we are interested in
function claim(uint256 index, address account, uint256 amount, bytes32[] calldata merkleProof) external;
function token() external view returns (address);
// ...rest
}
View the actual deployed contract here https://optimistic.etherscan.io/address/0xFb4D5A94b516DF77Fbdbcf3CfeB262baAF7D4dB7#code
In case you are unfamiliar with a Merkle Tree, it basically is a tree of hashes, which, in this case, is used to keep track of addresses and whether they have claimed. This approach is not new, and has been implemented for several airdrop campaigns over the years. You can sort-of imagine this being stored on chain as a more optimised mapping of addresses to booleans.
Approach
Great, so now the main task is to,
claim the token with address at
token()
,(if necessary) approve the token to be sent, and
transfer out everything to my
targetAddress
all in one transaction.
Let's break them into separate steps.
Claiming the token with a merkleProof
First, we have to figure out the parameters we want to call the claim()
function with, and the tricky part here is bytes32[] calldata merkleProof
.
Luckily, on Optimism's airdrop claim page, I can simply try to claim on my compromised wallet, and copy the raw transaction details. I can then just copy all the fields to use it elsewhere first. My parameters looked something like this,
{
"func": "claim",
"params": [
10763, // index
"0x...", // compromised address
75013910473982704730, // amount
[
"d06e1b3a2a0846df1819aeb763ea0c8c2e2d87bece719661d5d92cade0669d43",
"3378c9392bd1707556817a949153b4f8b50a87792d0479ee46f319a20e7884f0",
"5be1b049bce9ac45e96539d6446aae6e865223e42053fd821267f90d70ede051",
"ce034072738ceb89cd32d57784b92be4b2e641f9b7bdcedeea61ab0e8fa7214b",
"fb1e33c3549765139428cf47bea4662bc5be0ce9a2363488cad93a89095557b3",
"1bd1781ea2bec0c4d3615a602d015c0156b0ebbfbce9c54ec1a1e5a0ec635884",
"82b305509c9ca2e360ac3eacfb844c9246e6453d64e998359152ebe602dc7654",
"0a82dc4ada258b5e7134c184419d9dfd507709042c8836cf44ebbd9000d4d351",
"c085d7f862183a971375e3f700766b6467f81c7e568982f975e9e271bd1cde22",
"7d979126d4aec21bc0cb189224157287b64760728f1b2b77a8286d8ebe22cd77",
"892c8cd26238471277ce9e16a23b73a31782ff1af4982d5ce4cd9734fd67f1b9",
"794158348ac77811b5bcceefa88f307888df2e249497965d9e695cef95e98809",
"554ee65af86735d55e3966543d1eee1a67e7127cbad0e41d564027570f52c159",
"5b21251ac365150fbed19fd46684c580418efda2ea03363b746c9a751d5b5f7c"
] // merkleProof
]
}
Approving and transferring airdrop tokens
The tricky part for this is to make sure that the contract performing the claim()
has approval to send the tokens to our targetAddress
. If you've used any dApps that have some ERC20s, you would know that usually the frontend prompts you to sign the approve()
transaction first, before asking you to sign the transfer transaction.
It's not really possible to do the approval + transfer in one transaction without modifying the underlying ERC20 token / using something like Permit2, so I decided to do the approval in a separate transaction. This means that I would need to have sufficient gas to do 2 separate transaction calls!
Solution
So, I decided to combine the claim and transfer step into a contract for simplicity, and do the approval transaction before calling this custom contract.
The entrypoint Retriever
contract looks something like this,
// I chose to write out the interface instead of importing, reduce gas
interface IERC20 {
function transferFrom(address from, address to, uint256 value) external returns (bool);
}
contract Retriever {
function withdraw(
address distributerAddress,
address receiverAddress,
uint256 index,
address account,
uint256 amount,
bytes32[] calldata merkleProof
) external {
// the interface here is the same as above
IMerkleDistributor distributer = IMerkleDistributor(distributerAddress);
distributer.claim(index, account, amount, merkleProof);
IERC20 opToken = IERC20(distributer.token());
opToken.transferFrom(account, receiverAddress, amount);
}
}
Then, I combined all the steps into a Hardhat script and simulated it on a fork of Optimism mainnet.
My Hardhat config is,
import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
const privateKey = process.env.PRIVATE_KEY;
const infuraApiKey = process.env.INFURA_API_KEY;
if (!privateKey || !infuraApiKey) {
throw new Error(
"Please set your PRIVATE_KEY and INFURA_API_KEY in a .env file"
);
}
const config: HardhatUserConfig = {
solidity: "0.8.24",
networks: {
hardhat: {
forking: {
url: `https://optimism-mainnet.infura.io/v3/${infuraApiKey}`,
blockNumber: 118496140,
},
accounts: [
{
balance: "1000000000000000000000000", // replace with actual account balance
privateKey: privateKey,
},
],
},
optimism: {
url: "https://optimism-mainnet.infura.io/v3/${infuraApiKey}",
accounts: [privateKey],
},
},
};
export default config;
and the script looks something like this,
import hre from "hardhat";
// this was deployed in another contract
const retrieverAddress = "0x8d93cdfBe7996581B12eB58D7e90C357AEed5af6";
// const retrieverContract = await Retriever.deploy();
// const retrieverAddress = await retrieverContract.getAddress();
// console.log("Retriever deployed to:", retrieverAddress);
const compromisedAddress = "0x7730B4Cdc1B1E7a33A309AB7205411faD009C106";
const receiverAddress = "0x8A322f00b1097D343C824ff1BBcB2A78Be50C2D7";
async function main() {
const [signer] = await hre.ethers.getSigners();
console.log("Deploying contracts with the account:", signer.address);
const Retriever = await hre.ethers.getContractFactory("Retriever");
const retrieverContract = Retriever.attach(retrieverAddress);
const OpTokenContract = new hre.ethers.Contract(
// retrieved from MerkleDistributor.token()!
"0x4200000000000000000000000000000000000042",
[
"function approve(address spender, uint256 value) external returns (bool)",
"function balanceOf(address account) external view returns (uint256)",
],
signer
);
const tx = await OpTokenContract.approve(
retrieverAddress,
BigInt("999999999999999999999")
);
const approveReceipt = await tx.wait();
console.log({ approveReceipt });
const withdrawReceipt = await retrieverContract.withdraw(
"0xFb4D5A94b516DF77Fbdbcf3CfeB262baAF7D4dB7",
receiverAddress,
BigInt(10763),
compromisedAddress,
BigInt("75013910473982704730"),
[
// merkle proof from above
]
);
console.log({ withdrawReceipt });
const airdropAddressBalance = await OpTokenContract.balanceOf(
compromisedAddress
);
const receiverBalance = await OpTokenContract.balanceOf(receiverAddress);
console.log({ airdropAddressBalance, receiverBalance });
}
I ran the script with the compromised account, and made sure that it would work with the actual account balance. Luckily, it all went quite well, and the funds were secured.
Caveats
One concern when writing a custom Retriever
contract on any compromised account, is the chance of getting frontrun-ed.
If someone else had access to the compromised account, it is possible for them to,
claim the airdrop for themselves in the first place (then it would be a race against the clock with you!)
notice that you are making some transactions (in this case, approval transaction /
Retriever
contract creation on another account that could have been linked to the compromised one), and try and figure out what you are doing
Unfortunately, there's no real way to deal with the first issue. An easy way to "tackle" the second problem is to send your RPC requests through a private RPC like Flashbots, to avoid the attacker from seeing your on-chain activity (before block confirmation).
Additionally, if the attacker knew about the logic of your retrieval, they can potentially cause it to fail, e.g. some form of denial of service (DoS). A notable example would be for Permit2
, where an attacker can cause the entire transaction to fail when the permit()
function is being called. This was actually a large security finding, and I encourage you to take a look at the tweet below.
Conclusion
Regardless, I hope this article was helpful in demonstrating the approach to retrieving airdrop allocations on a compromised account, and executing any function call in general. Do always make sure to simulate all your transactions with some fork of the chain, as part of your end-to-end testing!