Monday, July 17th, QuadraticLenster launched and was a proud moment for Raid Guild, Lens and SuperModular. Quadratic Lenster, as a beautiful example of the composability of permissionless, open-sourced protocols that can build supermodular tech stacks and augment already powerful applications with composable superpowers. It also opens you up to vulnerabilities since the attack surface of an application grows with each integration. Today, we were reminded of that the hard way. We'll start this post-mortem with a TL;DR and will expand on the technicalities, process and lessons learned later on. Gratitude for everyone in our [Raid Guild](https://www.quadraticlenster.xyz/u/raidguild) network, including [Stargarden](https://www.quadraticlenster.xyz/u/st4rgard3n), [Dekan](https://www.quadraticlenster.xyz/u/dekan) and [Nilla](https://www.quadraticlenster.xyz/u/nilla), who were heads down with us in solving this challenge. > ### TLDR: > > - To allow for relaying votes from Lens to the Grants Stack we build a voting strategy that allows for indirect voting on Grants Rounds. > - Originally, LensHub would control the relayer so votes would be cast via an address we control. To focus on MVP and decoupled dependencies, we dropped the relaying. > - We unintentionally kept using the relaying contract which in turn allowed uncontrolled access to the related voting and the data encoded in the votes > - User with a higher allowance on WMATIC than they voted with, left the unspent allowance open for a malicious (re)player to cast 'votes' on an arbitrary address as the recipient of the voting funds. > - Vote, rinse, repeat, untill allowance is spent. > - Hotfix is live, old round will wind down and new round will be spun up with hotfix. > - Affected addresses have been identified and will be compensated for lost WMATIC. > - Grants funds are safu Based on comments provided by participants in the grants round we noticed an unusual pattern where votes were reexecuted but the fund routed to a different address. _The [post](https://lenster.xyz/posts/0x89dc-0x1133-DA-9fa9769b) that got the ball rolling:_ ![](https://s3.amazonaws.com/charm.public/user-content/7a8b633d-7253-4d26-9a94-8492f7df80c0/d2e97768-28bb-41fc-8c1e-3f6247dd3093/Screenshot-2023-07-19-at-14.59.19.png) ## What happened? After a user submitted a tip on the post they liked, their transaction was replayed multiple times until the available WMATIC was drained from their wallet. To be more precise, it wasn't all available WMATIC but the amount the contract was **allowed** to spend. ## How can that happen? This issue is an artifact of our development process and an oversight from our end. This section will focus on the technical details regarding how this could've happened. It covers Solidity and transaction data. Let's get into the technicals. ### Some context In an ideal world, Quadratic Lenster is a functionality of Lens that connects the Lens protocol to the Grants Stack. When a user creates a post they enable a Lens **Module** that connects the post to a Grants round. This module allows for other users to tip the content they value and the tip receives additional funding from the matching pool. We call this **Relayed Voting.** When the user tips, the Module encodes a vote that contains the following information: - tip token - tip amount - the post creator or projectID (!) - the grants round address - the address of the voter(!) Again, because the Module controls the tipping it knows and controls the **post creator address** encoded in the vote (**projectID** in the Grants Stack). And because the Module is in control of the Lens protocol, we know **the voter address**. When receiving a vote, we decode the encoded vote dataset and use the parameters to send the tipped amount to the post creator and submit a vote by emitting an event. _The Relayed voting code:_ ```Javascript function vote( bytes[] calldata encodedVotes, address relayerAddress ) external payable override nonReentrant isRoundContract { for (uint256 i = 0; i < encodedVotes.length; i++) { ( address _voterAddress, address _token, uint256 _amount, address _grantAddress, bytes32 _projectId ) = abi.decode( encodedVotes[i], (address, address, uint256, address, bytes32) ); if (_token == address(0)) { AddressUpgradeable.sendValue(payable(_grantAddress), _amount); } else { SafeERC20Upgradeable.safeTransferFrom( IERC20Upgradeable(_token), _voterAddress, _grantAddress, _amount ); } emit Voted( _token, _amount, _voterAddress, _grantAddress, _projectId, msg.sender ); } } ``` ### What went wrong? Under the hood, we worked around the Module flow so we could focus on release and Lens V2. While shifting architecture, we continued to rely on the Relayed Voting contract. However, we no longer had a relayer. This means any user can submit a vote with the encoded data the attacker provided and voting was successful. > It's like filling up the gas tank or renting a hotel room, the bank okays a certain amount of money to be spent. Once the bill is settled, the amount that is charged is changed to the actual charges. What happened with QL is someone, or someone's, used the okayed amount, even though the actual had been settled, like ordering room service to my room on the bill for your room, even though you already checked out ([Christina, BorrowLucid](https://www.quadraticlenster.xyz/u/borrowlucid)). #### The exploit We identified two exploit flows ([EOA 0x4Bfd2181](https://polygonscan.com/tokentxns?a=0x4bfd2181be8fa2f6702dee41a46baabeb5d3dd3d), [Contract 0x16e41a](https://polygonscan.com/address/0x16e41af5e034113802d06064fbeb020cc3d47b19)) based on two target addresses. For simplicity, we'll use one to explain the exploit. When a user casts a vote, they submit a transaction to a grants round. The grants round then does checks on some timestamps and routes the vote to a voting contract. The voting contract decodes the voting message and creates a token transfer from the voter to the round participant. To facilitate this, the user need to set an allowance for the grants round to spend x-amount of token. The allowance can either match the amount, be unlimited or somewhere in between. In the [original grants voting flow](https://github.com/bitbeckers/grants-round/blob/db4ee8e0980816353aa6b574c98177b943d32017/packages/contracts/contracts/votingStrategy/QuadraticFundingStrategy/QuadraticFundingVotingStrategyImplementation.sol#L55), the `msg.sender` is passed by the grants round as the `voterAddress`. So the round contract tells the voting contract to transfer the voted token amount to the grant recipient. In the case of the [relayed voting flow](https://github.com/bitbeckers/grants-round/blob/db4ee8e0980816353aa6b574c98177b943d32017/packages/contracts/contracts/votingStrategy/QuadraticFundingRelayStrategy/QuadraticFundingRelayStrategyImplementation.sol#L58), the `voterAddress` is encoded in the vote message. So a 3 party contract tells the round contract to transfer the voted token amount to the grant recipient. The architecture would only allow a trusted relayer to submit a vote. Specifically, a Lens Module would relay the vote so that the `voterAddress` gets encoded by a Module controlled in the Lens protocol. And this is the core of the problem, we used a relayed voting setup while not having a relayer. The attacker [0xc0ffeebabe](https://polygonscan.com/address/0xc0ffeebabe5d496b2dde509f9fa189c25cf29671) must've figured this out (or scripted for) and abused the voting flow by setting the projectID -or votee- to the contract which they presumably control. Because they'd act as the relayer, they could spend the allowance set to the grants round. We identified a similar attack for the EOA address. Here is a [Vote transaction](https://polygonscan.com/tx/0xba1da542200afcc09ef78ec23ecab4470bcae007e597864f76a06dbe8abd1d14) submitted by the attacker: _Sent by 0xc0ffeebabe_ ![](https://s3.amazonaws.com/charm.public/user-content/7a8b633d-7253-4d26-9a94-8492f7df80c0/ad66e329-39d0-4eaa-93d2-aed9a0455ee6/Screenshot-2023-07-19-at-15.33.43.png) You can see that the WMATIC is routed via the contract to 0xc0ffeebabe. _Voting on their 'grant round' 0x16e41..._ ![](https://s3.amazonaws.com/charm.public/user-content/7a8b633d-7253-4d26-9a94-8492f7df80c0/b58552df-081f-4405-af28-ff26822f59c5/Screenshot-2023-07-19-at-15.33.54.png) ## How we're fixing it #### Steps Multiple steps have been taken to block further drain attacks on the current round. 1. We [blocked tipping in the frontend](https://github.com/supermodularxyz/quadratic-lenster/pull/12). No tipping, no allowance, no drain. 2. We're [actively reaching out](https://www.quadraticlenster.xyz/posts/0x018db9-0x0a-DA-96b0a2a8) to comments on Lens that touch on this issue 3. We're updating in multiple developer channels to notify and explain the issue. 4. We've updated the round to end 19 Jul 2023, 17:15:56 UTC \[[tx](https://polygonscan.com/tx/0x2e702a83733e2e741df6641a66b36326e5596cd9bdf7ccf9fcaf2cdab87759f0)\] to block any other voting/allowances being set after that deadline. 5. We've [identified affected addresses]((https://bafybeidllqf3mwd3vnl3iwq2oml5msz5i6qihom6ylcpfxjzanqn2qhrka.ipfs.nftstorage.link/ipfs/bafybeidllqf3mwd3vnl3iwq2oml5msz5i6qihom6ylcpfxjzanqn2qhrka/tx_overview_allowance_drain/reimbursement-Payouts.csv)) and [executed an airdrop](https://polygonscan.com/tx/0xb450b3603fb1c3e865b8c09fbb6efc76368e7694487fcebe34cdc325f6f032bc) to return the WMATIC drained. 6. We've launched [a new round](https://www.quadraticlenster.xyz/rounds-overview) with the same #ethcc hashatg. When processing the voting data the old and new voting data will be merged, with the exception of the two malicious addresses. #### Data You can find our datasets on [IPFS](ipfs://bafybeidllqf3mwd3vnl3iwq2oml5msz5i6qihom6ylcpfxjzanqn2qhrka). The dataset consists of a Numbers file with txs to the attackers, the merged data and the CSV for the reimbursement drop. You'll also find every sheet in CSV format. #### Funds We've [returned the funds drained](https://polygonscan.com/tx/0xb450b3603fb1c3e865b8c09fbb6efc76368e7694487fcebe34cdc325f6f032bc) to affected users we could identify in a batch transaction -thanks Safe CSV app- yesterday. The following table gives and overview of the totals detected and transfered. | **Token** | **From** | **TXs (Count)** | **TokenValue (Sum)** | **USDValue (Sum)** | | ---------------- | ---------------------------------------------- | ---------------------- | -------------------- | ------------------ | | **WMATIC** | 0x187089b33e5812310ed32a57f53b3fad0383a19d | 112 | 30,35 | US$ 23,17 | | | 0x18fff78773a449d63ee954a681320572840bed18 | 2 | 2 | US$ 1,56 | | | 0x36f322fc85b24ab13263cfe9217b28f8e2b38381 | 1 | 1,5 | US$ 1,17 | | | 0x451671a146787fc1cf15794004bc7223a47d0eaf | 1 | 2 | US$ 1,56 | | | 0x45da50b5e5552ffd010e309d296c88e393d227ab | 102 | 102 | US$ 79,56 | | | 0x7c45fb10a18f834a47cc3f470e97140a399b6af8 | 1 | 1 | US$ 0,78 | | | 0x83455fcf356461654854fd376a6ac8922af60a8c | 4 | 4 | US$ 3,12 | | | 0x8f96aa1c4cbd3b10786c81031c6917bcac66423c | 3 | 3 | US$ 2,34 | | | 0xa7d53695af1fd11e0b75d37695290c102d59d743 | 30 | 150 | US$ 116,70 | | | 0xacf4c2950107ef9b1c37faa1f9a866c8f0da88b9 | 3 | 3 | US$ 2,34 | | | 0xafe2b51592b89095a4cfb18da2b5914b528f4c01 | 17 | 85 | US$ 66,30 | | | 0xb2ebc9b3a788afb1e942ed65b59e9e49a1ee500d | 1 | 4 | US$ 3,12 | | | 0xc3b8bbd76c78a0dfaf47b4454472db35cebd1a24 | 8 | 8 | US$ 6,24 | | | 0xcd92890603af492a4d2f8b07c5b1fca8606d2459 | 1 | 1 | US$ 0,78 | | | 0xd1d8e452a864388280b714537cbead6ff9e28530 | 9 | 8,5 | US$ 6,63 | | | 0xea47dc95d96db80ad330cb0a810b7b3507e22ab4 | 25 | 50 | US$ 39,00 | | | 0xec8e47e050a122970cbb4baf44413f3f070b543d | 22 | 22 | US$ 17,16 | | **Totals** | | **342 TXs** | **477,35 WMATIC** | **US$ 371,53** | #### Hotfix A hotfix on the voting strategy has been implemented. This was the most precise change we could make without refactoring the backend. With this fix, we are able to launch a new round. We are committed to honoring the -legit- executed votes and treating the misused round and the new one as the same. We basically nerfed the relay capabilities. The contract is still a relayed voting contract because the encoding and decoding of transaction data propegates throughout all system. This tweak in the business logic seemed most effective for the current context. A more sustainable fix will be implemented in the near future. The [Voting Factory](https://polygonscan.com/address/0x87d50e83799e8f36edcee39ca444a7388aee13a2#readProxyContract#F2) now uses the [updated voting implementation](https://polygonscan.com/address/0x25a8d29cd326c0ed281390d72620531a6a037dcd#code#F8#L78) with the hotfix _The hotfixed vote method. Notice the additional require statement_ ```Javascript function vote( bytes[] calldata encodedVotes, address relayerAddress ) external payable override nonReentrant isRoundContract { for (uint256 i = 0; i < encodedVotes.length; i++) { ( address _voterAddress, address _token, uint256 _amount, address _grantAddress, bytes32 _projectId ) = abi.decode( encodedVotes[i], (address, address, uint256, address, bytes32) ); require(_voterAddress == relayerAddress, "You are not the voter"); if (_token == address(0)) { AddressUpgradeable.sendValue(payable(_grantAddress), _amount); } else { SafeERC20Upgradeable.safeTransferFrom( IERC20Upgradeable(_token), _voterAddress, _grantAddress, _amount ); } emit Voted( _token, _amount, _voterAddress, _grantAddress, _projectId, msg.sender ); } } ``` ## What do you need to do? 1. Check your wallet. In case you were drained, you should be reimbursed via [our Safe](https://polygonscan.com/address/0x05164D0F50A4C63a468a8eB3FDBEeb4a5BD44289). If not, or you think something went wrong, please reach out to us. 2. To be safe revoke any open allowances on the vulnerable round contract (0xA2ae8421776035c398c22e143290697DA09d19D7). For example you can use [Revoke.cash](http://Revoke.cash) or Polygonscan. 3. Follow [owocki.lens](https://www.quadraticlenster.xyz/u/owocki) and [quadraticlenster.lens](https://www.quadraticlenster.xyz/u/quadraticlenster) for updates on reimbursements, payouts and new rounds. 4. Bear with us. We've launched a beta app and made a mistake. Quadratic Lenster is a powerful concept and we hope to bring a net-positive incentive mechanism to the town square. ## Lessons learned - The Lens community is lovely. You all have been kind and understanding 🌱 - Community reactions and responses are high-value feedback devices. - Review, review, review. We reviewed and tested of course. But we let this one slip through the architecture refactor. Bring in an external party, or friend, before prod to be more certain. - More admin rights during beta rounds, which enables a team to respond faster. - Unlimited, or high, allowances can bite ones behind. Most UX components deal with this, and luckily reasonable allowance were set. If any bag holders had unlimited allowances the financial impact would've been >1000x. - A system like OpenZeppelin Sentinel or a Dune Dashboard could've surfaced the spike in transactions. The amount of drain transactions was disproportional to the amount of tipping.