CircuitDAO, a CDP protocol for Chia, recently held a public bug bounty competition. CDP protocols - like MakerDAO, for those familiar with the Ethereum DeFi landscape - allow users to borrow an asset (BYC) based on collateral deposited in another asset (XCH). Users pay interest on their loans, part of which is distributed to BYC holders who stake it in savings vaults. Whenever the collateral value goes below a certain threshold, the assets are liquidated in an auction, ensuring BYC stays overcollateralized and maintains its $1 peg.
The Cantina competition took place after the CircuitDAO codebase went through an audit from Zellic and an Immunefy private competition. For one month, anyone could analyze CircuitDAO’s Chialisp puzzles on Cantina and file vulnerability reports. The prize pot was distributed according to the severity of confirmed reports (valid vulnerabilities), as well as a report’s number of duplicates (the more people find the same bug, the less weight it has in distribution, incentivizing unique findings). For more information about competitions - which nicely complement security audits - check out this section of the Cantina docs.
Even though I joined toward the competition’s end, I found 3 critical bugs and 2 medium vulnerabilities, ranking 1st across all participants. I curated the list below not only because the findings are interesting, but also because they show things Chialisp programmers should be careful about when designing and writing puzzles. I was the only one to report the vulnerabilities below. This article is not intended to raise any doubts about CircuitDAO (see the end of this post for more about this). I’m publishing these findings with the permission of the CircuitDAO team. Without further ado (or disclaimers), let’s dive right in!
Finding One: Liquidating Everyone
As previously mentioned, CircuitDAO has a simple yet efficient mechanism to keep all loans overcollaterized: when the value of the collateral goes below a percentage of the value borrowed (e.g., 200%), a liquidation process is started. But, to value an XCH deposit in terms of BYC (1 BYC = 1 USD), the CircuitDAO protocol needs a trusted, on-chain XCH-USD price feed - where do they get that from?
The answer is the protocol oracle, which consumes multiple price feeds, calculates the current XCH price, and then announces it on-chain. Feeds are approved by governance and represented on-chain by announcers.
Each announcer is a custom singleton that multiple stakeholders can call. Its owner can, for example, update the announcer’s custody puzzle hash or add a price. Meanwhile, keepers - entities keeping the CircuitDAO protocol working while pocketing some profit - can slash the capital locked in the announcer if the price is stale (was not updated for a while). They can also make the singleton announce the most recent price along with the announcer’s approval status, allowing the oracle to access it. Governance is the only entity that can set an announcer’s `APPROVED` parameter to 1 (true), allowing it to send data to the price oracle. Anyone can create and operate an unapproved announcer, but only approved announcers contribute to the price feed used by the CircuitDAO protocol.
My internal alarm bells went off when I noticed that anyone can exit an unapproved announcer (i.e., not only the announcer’s owner). The functionality was originally intended as a way for announcer owners to recover the funds they provide as a guarantee for their announcers (which can be slashed when the announcer is active). Due to a missing permission check, however, anyone can make an announcer exit into its owner puzzle (i.e., create a coin with a puzzle hash of `INNER_PUZZLE_HASH`) through this path as long as the announcer is not approved by governance (`APPROVED=0`). By itself, this finding has low severity at best, as the worst someone can do is exit announcers pending approvals in some cases. What makes this finding much more severe is that it breaks lineage. At a high level, an announcer custom singletons coin checks it is valid through the following checks:
My top (outer) layer is an announcer custom singleton with my state
My parent is either the launcher or another announcer custom singleton
By requiring the parent to also be an announcer, the protocol ensures there are no invalid state transitions. For example, no one can just create an approved announcer, as the launcher asserts `APPROVED=0` and further spends only set `APPROVED=1` if governance passes a bill that says so.
However, through the exit path, an announcer coin produces a child with an arbitrary puzzle that automatically satisfies the second condition! This is only possible because the inner puzzle only runs when the owner of the announcer runs a puzzle - if a keeper uses the previous bug to initiate the exit and create the new coin, the inner puzzle doesn’t get executed at all. If the proper permission check was in place, an additional constraint was placed on the inner puzzle (i.e., that it had to run inside an unapproved announcer), which would invalidate this attack path.
That leads us to our attack. An attacker creates an unapproved announcer - an action that anyone can do. In subsequent coin spends, they act as the owner to update the inner puzzle hash to one corresponding to the hash of an announcer outer layer with `APPROVED=1`, then act as a keeper to ‘exit’ the current announcer top layer without revealing/executing the inner puzzle. Even though the parent performed an exit operation, the child’s puzzle hash will correspond to an announcer outer layer, which satisfies the first condition above. The second condition (parent is announcer) is also satisfied, meaning the attacker just turned an unapproved announcer into an approved one without involving governance at all. Ouch!
Given the last sentence, proving severity isn’t hard. If an attacker can create one approved announcer out of nothing, they can create a majority of announcers. Using those, the attacker would be able to fully control the XCH-USD price feed. By setting the price of XCH to, say, 0.001 USD, all loans would become undercollateralized, triggering mass liquidations.
In general, Chialisp developers should be careful when it comes to state transitions. While formal verification - a type of audit that translates code and assumptions to logical proofs and shows those are consistent and valid - is ideal, just thinking about how an attacker can lead a coin to a state it shouldn’t be able to achieve can sometimes uncover critical vulnerabilities.
Finding Two: Bricking Auctions
The CircuitDAO protocol has a treasury, which is the place where loan interest payments (stability fees) go, and also the source of BYC paid as interest for locking up BYC in savings vaults. When the treasury has too much BYC, a surplus auction can be started, where people bid CRT (governance tokens, which will be burned) to buy the excess BYC. When the treasury balance dips below the minimum set by governance, recharge auctions start, where people bid to sell BYC and get freshly minted CRT in return.
Both auctions work over the span of multiple blocks. Bids are accepted from anyone as long as the amount is higher than the last bid, in which case the last bid is refunded. An auction ends when the current bid hasn’t been outbid for a certain amount of time (3 days, for example).
The bug that breaks the auction mechanism is a seemingly innocent lack of validation. When a user bids, they supply a puzzle hash (an address) where funds should be sent (either the bid refund or the auctioned asset). This parameter is kept in the auction’s state and used in a subsequent spend. It is not, however, sanitized at all. That seems fine at first - even though a puzzle hash is 32 bytes long, providing a value that’s another length (33 bytes long, for example) is not a problem, as the CAT layer will wrap whatever bytes are provided and generate an output hash. The new coin would be unspendable, but this would just be the equivalent of providing a wrong puzzle hash to the auction - which is user error, not a vulnerability.
The trick to break the auction is to provide a list as the puzzle hash. Whether you win the auction or get a refund (because you were outbid), a CREATE_COIN condition with a puzzle hash that’s a list instead of bytes will be emitted. When the CAT layer tries to wrap the list in a CAT outer puzzle, it will raise an error, making the whole bundle invalid. But that is not the bid bundle - instead, it’s the next user’s, who’s trying to outbid you or settle the auction. What’s worse, you can bid any amount - so, if you get lucky, you may be able to place a malicious .001 CRT bid on a 100.000 BYC auction and brick it forever. Surplus auctions store the BYC in a payout coin that would become unspendable after such an attack, meaning that the excess BYC would be lost forever.
Generally, always make sure to check all user input - the spend should fail if a wrong value (or a wrong type) is passed. This is especially important since Chialisp is not a typed language. As the vulnerability above shows, a failure in subsequent spends might be the reason there is an issue in the first place.
Finding Three: Stealing Bids
While writing a report for the last vulnerability, I also realized that the target puzzle hash is only used to set/update the last bid state. That means it didn’t appear in any other place in the code - so bidders had no way to assert the value is set correctly, as the solution of a bid coin is not signed, and no announcements contain the target puzzle hash. A malicious farmer could simply parse the original bundle and replace the included puzzle hash with theirs - that way, if someone outbid the current user, the funds would be sent to the farmer. And, if the user won the auction, the farmer would get the pot. Essentially, a user pays for the bid, but the farmer gets all the rewards.
This is bad on paper, but it’d take a lot of effort to set up a significant number of malicious farmers in practice due to Chia’s decentralization. There is, however, a trick to make this a critical vulnerability - enter Replace-by-Fee, a mechanism used by the bridge when another operation is already pending, TibetSwap when someone else is interacting with the same pair, the Dexie ‘bump fee’ feature, and probably most apps in the future. Pending spend bundles cannot just be replaced since that would open the mempool to several attacks. However, you can replace mempool items with new ones if the new bundle meets a few constraints:
All coins in the original bundle are still spent (superset rule)
The fee-per-cost of the bundle increases (more specific rules are irrelevant)
This opens up another possibility for the attack, where a malicious actor can steal all bids by simply watching the mempool. Whenever a spend bundle is detected, the attacker can take the original bundle, change the target puzzle hash, attach a higher fee, and send the new mempool item to the network. The old mempool item will be replaced by the ‘malicious’ one through Replace-by-Fee, as both the superset and fee increase constraints have been satisfied. The user paid to make a bid, but the attacker gets all the proceeds (refund or won lot).
In general, dApps should always provide a way for users to assert that they’re doing what they want to do. This can be more lax than just announcing all input parameters - offers, for example, allow you to assert that assets are sent to a puzzle hash without enforcing which coins need to be used, and their flexibility is what gives them power. The proposed security measure often comes in the form of extra output conditions, which will consume additional cost (‘make transactions bigger’) - but the alternative, as you can see above, is not a secure option. While Cantina’s rules would consider this bug family to be one vulnerability only, the attack above led to CitcuitDAO revising multiple areas of the code.
Should Security Be a Concern?
Security should always be a concern. On blockchains, the problem of security is even worse, as money locked in a protocol usually incentivizes hackers to exploit rather than report security issues. As a user, the real question is whether the probability of a hack is low enough to justify putting money in the protocol. In other words, yield is never risk-free, but users can weigh it against the protocol’s perceived risk.
Having said this, we should applaud the CircuitDAO team. Organizing and going through multiple audits/competitions is a time and capital-intensive process, even if you’re not using a lesser-known language such as Chialisp. Still, they’ve been audited by Zellic (one of my favorite top audit firms), held a private Immunefy competition, and just wrapped up a huge open competition on Cantina (where I participated for a week and reported the bugs above - and a few others). They’ll also soon launch a public bug bounty program a Cantina - if you know how to read Chialisp and would like a challenge, I highly encourage you to check it out!
Each bug reported before mainnet means a fix can be applied before the protocol goes into production. Users and community members should create strong incentives for protocols to go through thorough testing before launching instead of just “winging it” and needlessly putting funds at risk. While the instinctual reaction to hearing that critical bugs were uncovered is to be concerned, remember some protocols just choose to not perform any kind of security assessment. CircuitDAO, a very complex protocol with many moving parts, is undoubtedly on the right path to mainnet - the one with many security reviews.
So, as an end user, how do you know if the yield justifies the risk? I personally (not legal or any other kind of advice!) look for two things in highly secure protocols:
Multiple audits from reputable firms, with published reports
Few severe bugs in recent audit(s)/competition(s)
Security assessments can never guarantee there is no bug left - but I think the indicators above make it unlikely that the protocol has unresolved severe vulnerabilities.
Given the nature of this article, it’s fitting to end it the way I wrapped up posts on my old cybersecurity blog:
Until next time, hack the world.
yakuhito, over.