Reproduced Exploit
Catalyst IPSeed — a caller-supplied curve drains the ETH pool
spawn is public. The caller passes any IIPSeedCurve, and that address is written into tokenMeta with no whitelist. getBuyPrice and getSellPrice trust whatever that contract returns. An attacker spawns a second token whose curve buys at 0 and sells at the contract's whole ETH balance. They mint one…
Chain
Other
Category
logic
Date
Feb 2024
Source
AuditVault
EVM Playground
Source-level debugger — step opcodes and Solidity in sync
The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.
Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 31586-c-02-attacker-can-drain-all-eth-from-ipseed-due-to-malicious. Standalone Foundry PoC and full write-up: 31586-c-02-attacker-can-drain-all-eth-from-ipseed-due-to-malicious_exp in the
evm-hack-registrymirror.
Vulnerability classes: vuln/logic/missing-check · vuln/logic/price-calculation
Key info#
| Protocol | Catalyst (Molecule IPSeed) |
| Finding | Pashov Audit Group C-02, AuditVault 31586 |
| Vulnerable code | IPSeed.spawn, IPSeed.getSellPrice |
| Harm | 10 ETH taken from the shared pool; the honest buyer's exit reverts and their token stays stuck |
| Chain | local synthetic |
TL;DR#
spawn is public. The caller passes any IIPSeedCurve, and that address is written into tokenMeta with no whitelist. getBuyPrice and getSellPrice trust whatever that contract returns. An attacker spawns a second token whose curve buys at 0 and sells at the contract's whole ETH balance. They mint one token for free and burn pays them the 10 ETH an honest lab just deposited. The lab's later exit still quotes 10 ETH, the transfer reverts, and the lab token is still in the buyer's wallet.
The vulnerable code#
function spawn(
uint256 tokenId,
string calldata name,
string calldata symbol,
string calldata projectId,
IIPSeedCurve curve,
bytes32 curveParameters,
address sourcer
) public {
if (tokenId != computeTokenId(_msgSender(), projectId)) {
revert InvalidTokenId();
}
if (bytes(tokenMeta[tokenId].projectId).length > 0) {
revert TokenAlreadyExists();
}
// @> VULN: the caller-supplied curve is stored with no whitelist
Metadata memory newMetadata = Metadata(sourcer, sourcer, name, symbol, projectId, curve, curveParameters);
tokenMeta[tokenId] = newMetadata;
}
function getSellPrice(uint256 tokenId, uint256 sell)
public
view
returns (uint256 gross, uint256 net, uint256 protocolFee, uint256 sourcerFee)
{
// @> VULN: the sell quote is taken from the untrusted curve and paid in ETH
gross = tokenMeta[tokenId].priceCurve.getSellPrice(
totalSupply(tokenId), sell, tokenMeta[tokenId].curveParameters
);
(protocolFee, sourcerFee) = computeFees(gross);
net = gross - protocolFee - sourcerFee;
}
The report's fix is to accept only a whitelisted IIPSeedCurve. The control does that. The malicious spawn reverts, and the honest buyer can sell the token back for the same 10 ETH.
Root cause#
Price is not computed by IPSeed. It is an external call to an address the token's creator chose. Buy and sell then move ETH against that number, and every token draws on the same contract balance. A curve the protocol did not deploy can report 0 on the way in and the entire balance on the way out. The token-id check only ties the id to msg.sender. It does not constrain the curve.
Preconditions#
Anyone can call spawn for a token id derived from their own address. The contract already holds ETH from another token. Fees in this reduction are 0, so the quoted sell price is paid in full. A non-zero fee would pay the attacker the balance minus the fee. It would not stop the drain.
Attack walkthrough#
- An honest buyer holds 10 ETH and spawns a lab token on the published curve, which charges 10 ETH per token.
- The buyer mints one token. IPSeed's balance is 10 ETH.
- The attacker spawns a different token id and passes their own curve.
- That curve's buy price is 0. The attacker mints one token and sends no ETH. The pool is still 10 ETH.
- The attacker burns the token.
getSellPriceasks the attacker's curve, which returnsmsg.sender.balance, the full 10 ETH. burnsends 10 ETH to the attacker. IPSeed's balance is 0.- The buyer calls
exit. The honest curve still quotes 10 ETH, the transfer reverts, and the buyer still holds the token.
The control whitelists the published curve. The attacker's spawn reverts with UntrustedCurve. The buyer then sells and receives the 10 ETH back.
Diagrams#
Impact#
The attacker holds the 10 ETH that the honest buyer deposited. The buyer's token balance is still 1, and a later exit cannot pay the quoted 10 ETH because the pool is empty. The same curve can quote any larger balance that other labs have already deposited. The attacker does not need an admin key. spawn is public, and the token id only has to match the caller.
Remediation#
Reject curves that are not on a protocol whitelist before writing tokenMeta. Also cap a sale at the ETH that token actually brought in, so one curve cannot spend another token's deposits.
How to reproduce#
cd 31586-c-02-attacker-can-drain-all-eth-from-ipseed-due-to-malicious_exp
forge test -vv
Sources#
- AuditVault finding: https://github.com/Auditware/AuditVault/blob/main/findings/31586-c-02-attacker-can-drain-all-eth-from-ipseed-due-to-malicious.md
- Pashov report: https://github.com/pashov/audits/blob/master/team/md/Catalyst-security-review.md
- Reviewed tree: https://github.com/moleculeprotocol/desci-ecosystem/tree/e7980268004251020b47ba450c3c684dc0f38247
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 31586-c-02-attacker-can-drain-all-eth-from-ipseed-due-to-malicious_exp (evm-hack-registry mirror).
- AuditVault finding: 31586-c-02-attacker-can-drain-all-eth-from-ipseed-due-to-malicious.
Alerts & third-party analyses
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.