“look Ma’, No Source!” Hacking a Defi Service With No Source Code Available
By the Dedaub team
This story describes a cool hack, for over $300K (even nearly $600K, if done at the right time). It is a white-hat hack. We performed it off-chain, demonstrated to Dinngo, the authors of the vulnerable service, and they reproduced it and applied it to rescue the funds of exposed accounts, securing them.
The hack is among the most instructive we have encountered, which is why we wanted to document it clearly. There’s something in it for everyone: it showcases the danger of token approvals, interesting financial manipulation, the use of different DeFi services (Aave, Compound, Uniswap) as part of the attack, and much more.
Furthermore, this is a rare, if not the first, case of hacking a fairly complex smart contract without any source code available. (At the time of implementing and confirming the attack, we had no idea who was the owner of the vulnerable contract, so we were going by available bytecode only.)
Let’s start from the high level, and we’ll get more and more technical, both in the finances and in the coding.
The End-User’s View
The hack affects two parties: the victim account (a wallet, not a contract) which holds the funds, and the enabler contract, which contains the vulnerable code. The vulnerability in the enabler allowed us to drain the victim’s funds, because the victim had approved the enabler for all of its cUSDC (about $580K). In fact, there were several victims, but in the rest we only discuss the one we targeted, having an exposure 100x higher than the next closest.
If you are a DeFi end-user and want to get just one useful thing out of this article, this is it: be very careful with token approvals from your accounts. You are giving the approved spender contract the ability to do anything with your tokens. A vulnerability in the contract can drain your account. As something actionable, check out the new (in beta) Etherscan token approval feature (here demonstrated on our victim account).
Here’s what the victim’s account approvals looked like at the time of the hack:
Notice something strange? We highlighted one of the approvals. Of 110 token approvals, 109 were done to contracts with source code, which anyone can inspect. And one approval is to 0x936de89…: our enabler. Our enabler is also a public service: DeFlast.finance, created by Dinngo.
But the lack of source code for the contract should give you pause. See how it sticks out in the list above!
To be clear, this is not how we found out about the victim and the enabler. Instead, we are regularly running automated analyses on the entire blockchain that warn us about contracts worth inspecting closely. But the above is a likely way in which a black-hat hacker would identify that something is fishy about our victim and that the attack vector involves the enabler: some funds have been trusted to code that will likely be checked by very few people.
So, if you have accounts that interact with DeFi protocols or other token services, do yourself a favor and inspect your approvals. Your hacker may not be white-hat.
Attack: High-Level View
The vulnerable contract (our enabler), decompiled by contract-library, has a bit of complexity. We will analyze it a little later, but, even if reverse engineering is not your cup of tea, the high-level description is interesting.
The contract’s executeOperation
(called after an Aave flash loan, normally) takes as parameter a client account, two Compound cTokens, the flash loan balance, and some amounts. It then does the following:
- mints new cToken up to the specified amount
- liquidates (“redeems”) the client’s original cTokens (e.g., cUSDC) and transfers the underlying tokens to itself, the enabler
- swaps the tokens from the previous step on Uniswap v1 into the token of the loan
- repays the flash loan.
In the attack, the client is the victim account. But the code does not let anyone directly get the victim’s funds, it only forces a swap of the victim’s tokens from one kind of cToken into another.
So, how can this be exploited?
If you think about it in real-life terms, you already know the answer. You have someone forced to buy goods of your choice. How can you drain their funds?
By selling them worthless goods for a high price, of course!
Therefore, in order to attack, we did the following:
- create our own ERC20 token
- create a fake cToken (dummy methods, just returning the expected return codes) for this ERC20 token
- create a Uniswap v1 exchange and liquidity pool for our ERC20 token, so that it can be traded
- call the function, supply our parameters. The victim’s tokens (USDC) were transferred into our liquidity pool (after being converted to ETH), the victim got worthless tokens in exchange
- exit the liquidity pool, get ETH.
A cute element of the attack is that we don’t even need a sizeable liquidity pool to begin with — we can exploit Uniswap’s constant-product price calculation. That is, we don’t just make the victim buy worthless tokens, we make them buy 99.99+% of the worthless tokens’ supply, in order to drive the price up so much that the victim needs to spend all their assets! The exact percentage was carefully calculated based on the victim’s cUSDC balance.
If you think this is complex, consider this: we had never created either a cToken or a Uniswap v1 liquidity pool in code before, yet it took us only half a day to implement the basic attack. The steps are certainly well within reach of a sophisticated hacker.
The reality got complicated by a few nasty details, such as outstanding loans, extra swaps to counter slippage, etc. But the heart of the attack is well-captured in this summary.
Attack: Technical View
The first (but not foremost) complication in this attack is that the enabler contract (DeFlast’s) has no source code available. However, contract-library.com offers a reasonably good decompilation of it. Starting from the public executeOperation
function (typically the callback of an Aave flash loan) we can understand a lot of the code. Here are two key functions of the decompiled code, before any effort to manually improve:
function executeOperation(address _reserve, uint256 _amount, uint256 _fee, bytes _params) public nonPayable {
require(msg.data.length - 4 >= 128);
require(_params <= 0x100000000);
require(4 + _params + 32 <= 4 + (msg.data.length - 4));
require(!((_params.length > 0x100000000) | (36 + _params + _params.length > 4 + (msg.data.length - 4))));
v0 = new bytes[](_params.length);
CALLDATACOPY(v0.data, 36 + _params, _params.length);
MEM[v0.data + _params.length] = 0;
v1 = 0x148f(_reserve, this);
require(_amount <= v1, 'Invalid balance for the contract');
require(v0.length >= 128);
v2 = 0xdad(MEM[v0.data]);
v3 = 0xdad(MEM[v0.data + 32]);
0x13f6(MEM[v0.data + 96], _amount, MEM[v0.data + 32]);
0xe5b(MEM[v0.data + 96], MEM[v0.data + 64], MEM[v0.data]);
v4 = 0x10f2(this, v2);
v5 = _SafeAdd(_fee, _amount);
v6 = 0x11b5(v5, v4, v3, v2);
v7 = 0x10f2(this, _reserve);
v8 = _SafeAdd(_fee, _amount);
require(v7 >= v8, 'Token balance not enough for repaying flashloan.');
v9 = _SafeAdd(_fee, _amount);
0x15b1(v9, _reserve);
}
...
function 0xe5b(uint256 varg0, uint256 varg1, uint256 varg2) private {
v0 = address(varg0);
MEM[v1.data] = varg1;
v2 = address(varg2);
require(v2.code.size);
v3, v4 = v2.transferFrom(v0, this).gas(msg.gas);
require(v3); // checks call status, propagates error data on error
require(RETURNDATASIZE() >= 32);
require(1 == v4, 'Failed to transfer cToken from user when redeeming');
v5 = address(varg2);
v6 = v1.data;
require(v5.code.size);
v7, v8 = v5.approve(v5, varg1).gas(msg.gas);
require(v7); // checks call status, propagates error data on error
require(RETURNDATASIZE() >= 32);
require(1 == v8, 'Failed to approve cToken to Token Contract when redeeming');
v9 = address(varg2);
require(v9.code.size);
v10, v11 = v9.redeem(varg1).gas(msg.gas);
require(v10); // checks call status, propagates error data on error
require(RETURNDATASIZE() >= 32);
require(!v11, 'Failed to redeem underlying token.');
v12 = 0xdad(varg2);
v13 = 0x10f2(this, v12);
v14 = address(varg2);
emit 0xaface4c9957b8058dd049dc2a148905af00a14f8ef10dc658a81d03f527ab906(v14, v13);
return ;
}
After an afternoon of manual polishing, here’s the result of our reverse engineering for the same two functions:
// _reserve is the underlying token of ctoken1, or they both pretend it is
// ctoken0 has to be a true CToken: CUSDC
// numTokens is the amount of the victim's CTokens we want to/can get
function executeOperation(address _reserve, uint256 _amount, uint256 _fee, bytes _params) public nonPayable {
require(_params.length <= 256);
require(_amount <= getBalance(_reserve, this), 'Invalid balance for the contract');
// need to have a balance with token _reserve
ctoken0 = _params[0]; // certain ctoken
ctoken1 = _params[1];
numTokens = _params[2];
owner = _params[3];
token0 = getUnderlyingForCToken(ctoken0);
token1 = getUnderlyingForCToken(ctoken1);
mintCTokenForOwner(owner, _amount, ctoken1); // mint amount of ctoken and transfer to owner
redeemCTokenReceiveUnderlying(owner, numTokens, ctoken0);
// get owner's ctoken, redeem it, get underlying token in "this" contract
v4 = getBalance(this, token0);
amountPlusFee = _SafeAdd(_fee, _amount);
v6 = swapTokens(amountPlusFee, v4, token1, token0);
// swaps (on Uniswap v1) the tokens this contract got, to have enough to repay the loan
v7 = getBalance(this, _reserve);
v8 = _SafeAdd(_fee, _amount);
require(v7 >= v8, 'Token balance not enough for repaying flashloan.');
v9 = _SafeAdd(_fee, _amount);
repayFlashLoan(v9, _reserve);
}
function redeemCTokenReceiveUnderlying(uint256 owner, uint256 numTokens, uint256 ctoken) private {
ok, v4 = ctoken.transferFrom(owner, this, numTokens).gas(msg.gas);
require(1 == v4, 'Failed to transfer cToken from user when redeeming');
v5 = ctoken;
ok, v8 = ctoken.approve(v5, numTokens).gas(msg.gas);
require(1 == v8, 'Failed to approve cToken to Token Contract when redeeming');
ok, v11 = ctoken.redeem(numTokens).gas(msg.gas);
require(!v11, 'Failed to redeem underlying token.');
v12 = getUnderlyingForCToken(ctoken);
v13 = getBalance(this, v12);
emit 0xaface4c9957b8058dd049dc2a148905af00a14f8ef10dc658a81d03f527ab906(ctoken, v13);
return ;
}
Keep in mind that, at the time of doing this, we had no idea what high-level service uses this contract — we had not linked it to DeFlast, nor even knew what DeFlast was. But the contract’s intent is not too hard to discern from the code: a user’s cTokens are swapped for different cTokens (specified in the signature) with the help of a flash loan. First, the flash loan funds allow minting the new cToken. Then, the old cTokens are redeemed. The proceeds of the redemption are swapped on Uniswap v1 to get enough underlying “old tokens” to repay the loan.
However, there is no safeguard to ensure that this code is indeed called after a flash loan. But even that alone would not have been safe: one could get a minuscule flash loan and call the contract with the desired parameters. More importantly, the code does not check that the flash loan “reserve” token is the same as the “underlying” of the new cToken, nor that what the user gets back is real cTokens (and not merely something pretending to be a cToken).
So, we have a forced swap in our hands. All we need to do is make sure the code doesn’t crash from underneath us. We can create our own worthless token, wrap it in a cToken, and we can build our own market for trading them. In fact, our cToken can be entirely fake: it just needs to return the right underlying
token (our worthless token) and provide the expected return values: return 0 for mint
and redeem
, true for transfer
and approve
, etc.
pragma solidity ^0.7.0;
contract CMyToken {
address private _underlying;
constructor (address underlying) public {
_underlying = underlying;
}
function underlying() public view returns (address) {
return _underlying;
}
// funny how you think this matters
function exchangeRateCurrent() public pure returns (uint256) {
return 10 ** 18;
}
function mint(uint ) public pure returns (uint256) {
return 0; // means no error
}
function transfer(address, uint) public pure returns (bool) {
return true; // whatever you say, boss
}
function transferFrom(address, address, uint256) public pure returns (bool) {
return true; // at your command
}
function approve(address, uint256) public pure returns (bool) {
return true;
}
function redeem(uint) public pure returns (uint) {
return 0;
}
}
We then created an exchange for our token on Uniswap (v1, since that’s what the vulnerable code uses) and added a little bit of liquidity to it — about 0.001ETH against a tiny amount of our worthless token.
The beauty of Uniswap’s model is that it is so amazingly general, yet robust. It allows anyone to create an exchange and provide liquidity. Prices are determined entirely on-chain. However, the reliability of Uniswap prices depends on others jumping in and correcting exchange rate anomalies. Yet in our forced swap, there are no “others”! The market never gets a chance to adjust the price and restore our worthless token to its … worthlessness. (Even if a bot had been tempted to trade with us, we installed a trap in our worthless token, not allowing it to be traded outside the attack transaction.)
By instructing the enabler contract to trade the victim’s cTokens for our cTokens we can perform a successful attack. As mentioned earlier, we deliberately caused enormous slippage: our pool initially had just 0.001ETH against 0.0000001 of our worthless token. Still, we instructed the enabler to swap for over 99.9996% of the worthless token’s supply — the exact number being computed so that it would exhaust the victim’s funds.
A further complication is that the victim was using their cUSDC as collateral for Compound loans. The loan view of the account looked like this:
The total value of outstanding loans at the moment of the attack was around $280K, with collateral at $580K. A direct attack cannot get the $300K difference but only about two-thirds of that, since the Compound Comptroller would not allow transferring out money that would violate the loan collateralization limits. But this is easy to address: we just take $280K in flash loans, repay the victim’s loans, drain the $580K and pay off the flash loans.
A final complication is that the Uniswap v1 pools are too shallow nowadays. The USDC pool has around $650K liquidity at the time of this writing. Since the vulnerable code forces a swap of the proceeds on Uniswap v1, we suffer tremendous slippage. A Uniswap v1 swap between USDC and our worthless token is really two swaps with ETH in the middle: first USDC to ETH, then ETH to our token. The first of these swaps, for $580K out of the $650K available, nets a lot less ETH than it should.
However, this is easily countered: once we exit our own liquidity pool, before the end of the transaction, we perform an inverse swap of ETH for USDC and exploit all the slippage we just caused. In the end, we are left with the right amount of the victim’s USDC.
Actual Rescue Operation
The above is the attack we performed locally last week (last of Jan. 2021), confirming the vulnerability. We then made an effort to locate the owner of the victim account, but a couple of messages (speculative, based on past activity) yielded nothing.
Only at that point did we search for the owners of the enabler contract and got a link to DeFlast.finance! This was a relief. Not only did we now have a contact that could authorize a white-hat attack, but the contact was a high-quality team —also behind other projects that we had recently inspected thoroughly.
We contacted Hsuan-Ting Chu, the CEO of Dinngo, since he was the most obvious point of contact for escalating the report of a critical vulnerability. Within a few hours we were in a meeting with Hsuan-Ting and Dinngo engineers where we presented the attack.
The Dinngo team took over the rescue operation, following the blueprint of our attack, and moved the victim’s positions to another wallet. Other victims were similarly moved in the past 48hours. The operation was done very smoothly and professionally, especially considering the complexity of the attack (check out the transaction for the main victim)!
Concluding
This was a cool hack. It started from a bad smell: code that didn’t seem to be checking that it’s used only in its intended scenarios. Despite not having source code, we followed a hunch and spent some time reverse engineering. The vulnerability then required financial manipulation. Creating an exchange. Exploiting slippage. Getting flash loans. Paying off Compound loans. Countering slippage.
All in a day’s work…