Each exploit has a more detailed analysis in its own folder, with the vulnerabilities of the contract(s), the addresses involved and the exploit itself with the transactions the hacker made to exploit the contract.
- FTX Gas Abuse - 81 ETH "used" 71 ETH gained (2022-10-12)
- TempleDAO - 1,830 ETH (2022-10-11)
- Meebits - 200ETH (2021-05-08)
- Proof of Weak Hands Coin - 866 ETH (2018-02-01)
- Multi-sig Wallet 2, Parity Bug - >500,000 ETH lost (2017-11-06)
- Multi-sig Wallet - 150,000 ETH (2017-07-19)
In October 2022, a user abused free gas, offer by the FTX exchange for their withdraw, to mint XEN token, which requires no ETH, only gas for the transaction. It costed FTX over 81 ETH in gas fee and the exploiter made over 61 ETH by swapping the obtain XEN for ETH, WBTC and USDC.
The FTX exchange offer free withdraw (transaction sent from the FTX address), with a transaction where the gas limit is set to 500'000. The withdraw transaction call the fallback()
function on the address where the funds are to be sent, if the address is a contract, it will then executed whatever code is in that function until the gas runs out. With a gas limit of 500'000, there is a lot of gas left after the ETH transfer. A user can then use that gas to do anything they want.
A user decided to use that free gas to mint some XEN. XEN is an ERC-20 token where the mint function requires no ETH just the gas needed for the transaction.
The exploiter deployed an exploitContract and made the FTX exchange send it some ETH.
When the FTX exchange called the exploitContract fallback()
function to sent some ETH (0.0035ETH), the exploitContract will use as much as the 500'000 gas limit to mint XEN tokens. It first deploys a dummy contract, mint the token with XEN.claimRank(1)
with this contract and then selfdestruct. He finally send the received ETH to the exploiterAddr. Since some gas is left, he does it again with other dummy contracts. An address can only do one mint at the time, and has to wait at least a day until it can withdraw the token to mint again. He did dozens of similar transactions. All gas fee are paid by the FTX exchange.
After a day, the tokens can be claimed, so the contracts will now call XEN.claimMintRewardAndShare(exploiterAddr, 100)
, claiming their token and sharing them all with the exploiter address. It will then remint some token (and claim them a day later). Finally it selfdestruct the dummyContract. Since at this point there is still some unused gas, he does the same two more time with two other dummy contracts. At the end, the fallback()
function send the received 0.0035ETH to the exploiter address. Again, since the FTX address is the sender of the transaction, so it pays all the gas fee.
In one transaction, the exploiter ends up with around 171,000 XEN, the FTX address loses around 0.01 ETH in gas fee.
Poor access control on the migrateStake()
function of StaxLPStaking:
/**
* @notice For migrations to a new staking contract:
* 1. User/DApp checks if the user has a balance in the `oldStakingContract`
* 2. If yes, user calls this function `newStakingContract.migrateStake(oldStakingContract, balance)`
* 3. Staking balances are migrated to the new contract, user will start to earn rewards in the new contract.
* 4. Any claimable rewards in the old contract are sent directly to the user's wallet.
* @param oldStaking The old staking contract funds are being migrated from.
* @param amount The amount to migrate - generally this would be the staker's balance
*/
function migrateStake(address oldStaking, uint256 amount) external {
StaxLPStaking(oldStaking).migrateWithdraw(msg.sender, amount); //exploit
_applyStake(msg.sender, amount);
}
This function allows anyone (missing modifier) to submit any address (missing check) as the oldStaking
parameter.
The first line call migrateWithdraw
on the supplied address, anyone can choose what this functin does (eg nothing at all), and the second line increase the msg.sender's balance on the contract, since the contract thinks that the msg.sender had something staked that was just migrate:
function _applyStake(address _for, uint256 _amount) internal updateReward(_for) {
_totalSupply += _amount;
_balances[_for] += _amount;
emit Staked(_for, _amount);
}
Once this is done, we can just call withdrawAll()
which will transfer the given amount of LP tokens to our msg.sender address.
In May 2021, an attacker, armed with the list of rare Meebits tokenIds, managed to brute force the early mint of the NFT and revert all transactions except the ones that would mint a rare NFT. He managed to mint one before the mint was paused, and sold it for 200 ETH.
Inside a NFT collection, some token are more rare than others, and thus more valuable. The rarity is baseed on the token's traits. Unfortunately, for the Meebits NFT, the contract contained a file with the metadata of the collection, containing the traits and their rarity for each tokenId. So when you minted an NFT, you could know its relative value.
// IPFS Hash to the NFT content
string public contentHash =
"QmfXYgfX1qNfzQ6NRyFnupniZusasFPMeiWn5aaDnx7YXo";
Since the mint()
function returns the id of the minted token, a contract could easily call the mint()
function and revert
if the returned id was not in a list of rare tokenId.
The attacker deployed his attack contract, with a list of rare tokenId, and an attack()
function which simply called mintWithPunkOrGlyph()
and made sure the minted token was rare, otherwise the tx would revert. It also sent 1 ETH to the miner to ensure that the transaction would be added to the block:
function attack(uint _punkId) public{
uint256 tokenId = meebits.mintWithPunkOrGlyph(_punkId); // mint a meebits
require(rareMeebits[tokenId]); //check if its a rare one, revert if not
block.coinbase.call{value: 1 ether}(""); // pay the miner
}
He then quickly sold Meebits #16647 for 200 ETH.
4chan decided to create a crypto ponzi scheme, which was advertised as such, and obviously it worked well. In only three days it had over 1,000 ETH, but an underflow vulnerability allowed someone to withdraw 866 ETH from the ponzi.
There is two issues in this contract: an underflow vulnerability and a poor internal logic that can potentialy lead to this underflow.
The underflow vulnerability of the contract is in the sell()
function:
function sell(uint256 amount) internal {
var numEthers = getEtherForTokens(amount);
// remove tokens
totalSupply -= amount;
balanceOfOld[msg.sender] -= amount; //** possible underflow if amount is bigger that the balance
// fix payouts and put the ethers in payout
var payoutDiff = (int256)(
earningsPerShare * amount + (numEthers * PRECISION)
);
payouts[msg.sender] -= payoutDiff;
totalPayouts -= payoutDiff;
}
sell()
is called when a user call tranfer()
with _to
equal to the contract address. There is in fact a check to ensure that when a user wants to sell his tokens, he has that amount of token. But since transferTokens(_from, _to, _value)
allows a user to transfer tokens from a different address (who has previously approved the user to do so), the check is done for that _from
address and if that address indeed has those tokens, the check pass. But when sell()
decrease the balance, it does so to the msg.sender
address instead of the _from
so if the user (msg.sender
) has less tokens that the amount passed in _value
, an underflow will occur. The error is that _from
is not passed to sell()
as an argument.
The attacker used an underflow to increase his token balance to the maximum amount and then used that balance to exit the Ponzi with a lot of ETH.
He first used a second account (0x945) to buy some tokens and to approv his first account (0xb9cd) to transfer some of his tokens. Now that he can transfer tokens on behalf of his other account, he called transferFrom(_from_=0x945, _to=PonziContract, _value=1)
. transferToken()
check that the balance of _from
is high enough to transfer _value
of tokens (it is) and since _to
is the address of the contract, call sell()
. But since sell()
doesn't receive the _from
parameter (0x945), it assumes that it is the msg.sender
(0xb9cd), which doesn't have any token, so balanceOfOld[msg.sender] -= amount
underflow and is now equal to the maximum amount of uint256
.
He then sold some of his tokens and called withdraw()
which allowed him to exit the Ponzi with over 866 ETH:
After the MultiSig attack of July 2017 (where anyone could reinitialize any multisig wallet thanks to initWallet()
, become owner, and withdraw all funds), the Parity team deployed their new Parity wallet library with the previous vulnerability "fixed".
A few month later, a user "accidentally" managed to suicide the library contract by calling the very same initWallet()
function, since there had been no initialization on the library contract.
The main vulnerability again involve initWallet()
, only now it can only be called once. The function was correctly written, from the point of view of the Wallet contract, to make it uncallable after been initialized once. But since it was never called on the library contract itself, any one could call it and become owner of the library. Becoming owner of the library is not very interesting, you can change the library storage a bit but that is all.
The big problem comes from the fact that the contract was a library, which was used by a lot (500+) of different Parity Wallet smart contract. If this library were to disappear (become uncallable) those contracts who rely on it for their logic would simply become unusable.
The library have a kill()
function that call selfdestruct()
(suicide()
). It is protected by ownership, but since initWallet()
can give ownership to anyone, the first user to call initWallet()
can now destroy the library and all attached contracts.
When the library gets killed, all contracts whose functions delegatecall()
to it will no longer work. Since, in the Parity Wallet, the library address is a constant variable and thus hardcoded in the bytecode of the contract once deploy:
address constant _walletLibrary = 0x863df6bfa4469f3ead0be8f9f2aae51c91a907b4;
those contracts become completely unusable.
The attacker first called initWallet([attacker_addr],0,0)
to become owner of the library contract and then simply destroy it with kill()
. He then opened an issue on Github:
The multi sig wallet works with a library and use delegatecall
. If someone calls initWallet()
on the Wallet, it will delegate the call to the Library which will execute within the context of the Wallet contract:
function initWallet(
address[] _owners,
uint256 _required,
uint256 _daylimit
) {
initMultiowned(_owners, _required); //sett owner and n. of required sig
initDaylimit(_daylimit); // set daily limit for withdraw
}
With initMultiowned()
:
// constructor is given number of sigs required to do protected "onlymanyowners" transactions
// as well as the selection of addresses capable of confirming them.
// change from original: msg.sender is not automatically owner
function initMultiowned(address[] _owners, uint256 _required) {
m_numOwners = _owners.length; //1
m_required = _required; //0
for (uint256 i = 0; i < _owners.length; ++i) {
m_owners[1 + i] = uint256(_owners[i]); //attacker becomes owner
m_ownerIndex[uint256(_owners[i])] = 1 + i;
}
}
So by calling initWallet([attacker_addr], 0,_)
, the attacker becomes owner of the Wallet, change the number of signature required to 0 and the daily limit of widrawnable funds to the balance of the wallet. He can then withdraw the funds by calling execute(attacker_addr, amount, _)
:
// Outside-visible transact entry point. Executes transaction immediately if below daily spend limit.
// If not, goes into multisig process. We provide a hash on return to allow the sender to provide
// shortcuts for the other confirmations (allowing them to avoid replicating the _to, _value
// and _data arguments). They still get the option of using them if they want, anyways.
function execute(
address _to,
uint256 _value,
bytes _data
) onlyowner returns (bool _callValue) {
// first, take the opportunity to check that we're under the daily limit.
if (underLimit(_value)) {
SingleTransact(msg.sender, _value, _to, _data);
// yes - just execute the call.
_callValue = _to.call.value(_value)(_data); // transfer funds to attacker
} else {...}
}