Anyone Can Drain This Prediction Market Contract
How it's meant to work
The system has a converter (TokenConverter) whose job is to cash out leftover market tokens. A market hands out YES/NO tokens, the protocol ends up with some, and the converter redeems them back into the underlying, so it basically does this:
- Ask the market for its status and who won
function _convertMarketTokens(address market) internal {
ITruthMarketV2 truthMarket = ITruthMarketV2(market);
MarketStatus status = truthMarket.getCurrentStatus();
address yesToken = truthMarket.yesToken();
address noToken = truthMarket.noToken();
uint256 yesBalance = IERC20(yesToken).balanceOf(address(this));
uint256 noBalance = IERC20(noToken).balanceOf(address(this));- If it's Finalized and YES won, grab its balance of the market's winning token, approve the market over it, and call redeem
if (status == MarketStatus.Finalized) {
// Market is finalized, use redeem/withdraw logic
uint256 winningPosition = truthMarket.winningPosition();
if (winningPosition == 1) { // YES won
if (yesBalance > 0) {
IERC20(yesToken).approve(market, yesBalance);
truthMarket.redeem(yesBalance);
convertedAmount = yesBalance;
}- The underlying comes back as shares, and later an operator sweeps it to the safeBox with withdrawAllToken
The market minted these tokens and it's the one that knows its own outcome, so the converter asks the market. For a real market this is perfectly fine, which is exactly why it shipped.
But it never checks the market is real
And that's the one thing it forgot to do, check if market is even a real market. It takes whatever address you give it and believes everything that this contract returns.
No fancy math here, no flashloans, no inflation. Just giving approval to a contract that we have not checked. nonReentrant is slapped on top like that's gonna help D:
convertSingleMarket(market) (or convertTokens for a batch) is permissionless and passes whatever you give it straight into _convertMarketTokens, which then asks the contract for everything it needs to know:
ITruthMarketV2 truthMarket = ITruthMarketV2(market);
MarketStatus status = truthMarket.getCurrentStatus();
address yesToken = truthMarket.yesToken();
address noToken = truthMarket.noToken();
uint256 yesBalance = IERC20(yesToken).balanceOf(address(this));getCurrentStatus, yesToken, winningPosition, all of it comes from market. So you can deploy a contract that returns what you want, like if the market is Finalized, that YES won, and yesToken is some token the converter is actually holding. It checks its own balance, sees it's not zero, and then does the funny part:
if (winningPosition == 1) { // YES won
if (yesBalance > 0) {
IERC20(yesToken).approve(market, yesBalance);
truthMarket.redeem(yesBalance);It approves your contract over its full balance of that token, then calls your redeem. Your redeem just does transferFrom(converter, you, balance). The theft happens inside your contract, so nonReentrant is just placebo.
The attack
- Deploy
FakeMarketthat returnsgetCurrentStatus = Finalized,winningPosition = 1,yesToken = <the shares the converter is holding>, and whoseredeemjust doestoken.transferFrom(converter, attacker, amount) - Call
convertSingleMarket(fakeMarket) - Converter approves
FakeMarketover its full balance of that token - Converter calls
FakeMarket.redeem(balance) FakeMarketpulls the balance, converter is now empty
The fix
Just check that market is real before you touch it. Keep a TruthMarketManager reference on the converter (set it in initialize or behind DEFAULT_ADMIN_ROLE):
+ ITruthMarketManager public marketManager;
+ error InvalidMarket(address market);
function _convertMarketTokens(address market) internal {
+ if (!marketManager.isActiveMarket(market)) revert InvalidMarket(market);
ITruthMarketV2 truthMarket = ITruthMarketV2(market);
MarketStatus status = truthMarket.getCurrentStatus();One line of "is this even real" and the whole thing is dead.
The damage is capped to whatever the converter is holding at the time, so it's protocol revenue in transit, not user collateral. Not exactly Wormhole money. On Base it's currently guarding a fat 69$ (nice), but the door is wide open and that balance only goes up.
Read the actual bug report (and POC) here.
Building something that moves other people's tokens? If you have a mainnet launch, upgrade, or token event coming and your contracts have not been re-audited, you are running on luck.