| Safe Haskell | Safe-Inferred |
|---|---|
| Language | Haskell2010 |
Convex.ThreatModel.RedeemerAssetSubstitution
Contents
Description
Threat model for detecting Redeemer Asset Substitution vulnerabilities.
What vulnerability this detects
A Redeemer Asset Substitution Attack exploits validators that trust asset identifiers (policy IDs or token names) provided in the redeemer without proper validation against the datum or transaction context.
Attack scenario
Consider a validator that accepts a redeemer like:
SellRedeemer { sold_policy_id: ByteArray, sold_token_name: ByteArray }
And only checks:
// VULNERABLE: Trusts redeemer-provided asset without datum cross-check let token = find_token_in_inputs(redeemer.sold_policy_id, redeemer.sold_token_name) expect token.quantity > 0
Without verifying that the provided asset matches what was actually intended (e.g., a specific token name stored in the datum).
Real-world example: Purchase Offer CTF
The purchase_offer CTF contract stores a desired policy ID and an optional
token name in the datum:
Datum { owner: Address, desired_policy_id: PolicyId, desired_token_name: Option<ByteArray> }
When desired_token_name is None, the validator accepts ANY token from
that policy. An attacker can:
- See an offer for a valuable NFT (e.g., "RareNFT") from policy P
- Acquire a worthless token "WorthlessJunk" under the same policy P
- Fulfill the offer with "WorthlessJunk" instead of "RareNFT"
- Claim the locked ADA, leaving the victim with a worthless token
How this threat model works
This threat model uses a "swappable pair" approach that is Phase 1 valid:
- Find a script input — a non-key address input being spent
- Get its redeemer — extract the ScriptData redeemer
- Extract ByteString fields — these are potential token names
- Get all transaction outputs
- Find the first output with a token
(policyP, originalName)whereoriginalNamematches one of the redeemer ByteStrings - Find a second output (different from the first) containing a DIFFERENT
token
(policyP, otherName)from the SAME policy whereotherName /= originalName - Swap the tokens between the two outputs:
- Output1: remove
(policyP, originalName), add(policyP, otherName)- Output2: remove(policyP, otherName), add(policyP, originalName) - Substitute the redeemer: replace
originalNameByteString withotherNamein the redeemer - Check validation: the modified transaction should NOT validate
If the modified transaction validates (accepting the swapped token names), the validator is vulnerable because it accepts any token name without cross-checking the datum.
Why Phase 1 validity matters
Cardano transactions go through two phases of validation:
- Phase 1: Ledger rules checking (value preservation, signatures, etc.)
- Phase 2: Script execution (Plutus validators)
Phase 1 enforces that total value in = total value out + fees. A transaction that claims to send a token that doesn't exist in any input would be rejected at Phase 1 before the validator script even runs.
By swapping EXISTING tokens between outputs (rather than inventing non-existent tokens), this threat model creates transactions that pass Phase 1 and actually reach the validator for Phase 2 execution. This tests the real attack scenario where an attacker possesses a worthless token from the same collection.
Preconditions required
The transaction must contain at least two different tokens from the same policy in different outputs. This naturally happens when:
- The wallet holds multiple tokens from the same policy (e.g., a valuable NFT and a worthless one from the same collection)
- Coin selection includes a UTxO containing extra tokens from the same policy
- The fulfill transaction sends one token to the contract owner and returns another as change
How to satisfy preconditions in TestingInterface
When writing a TestingInterface instance,
the perform action for the relevant scenario should ensure the wallet holds
multiple tokens from the same policy.
Example approach:
- In the setup/mint action, mint BOTH a "valuable" token AND a "worthless" token from the same policy to the attacker's wallet
- When the attacker calls
performon the fulfill action, coin selection will naturally include the UTxO containing both tokens - The fulfill transaction will have one token going to the contract owner's output and the other in the change output
- The threat model can now find the swappable pair and test the vulnerability
This mirrors the real attack scenario: the attacker legitimately possesses a worthless token from the same NFT collection and uses it to fraudulently fulfill an offer meant for a valuable token.
Consequences of the vulnerability
- Asset theft: Attackers fulfill offers with worthless tokens
- Protocol manipulation: Wrong assets can satisfy contract conditions
- Value extraction: Locked funds can be drained with substitute tokens
Mitigation
A secure validator should:
- Store specific asset identifiers (including token name) in the datum
- Always validate redeemer-provided values against datum or script context
- Never trust attacker-controlled redeemer data for asset identification
- Use token name in datum when specificity is required
Synopsis
Threat models
redeemerAssetSubstitution :: ThreatModel () Source #
Check for Redeemer Asset Substitution vulnerabilities using the swappable-pair approach.
This threat model:
- Finds a script input and extracts its redeemer
- Extracts ByteString fields from the redeemer (potential token names)
- For each ByteString, interprets it as an
AssetNameand looks for an output containing a token(policyP, originalName)matching that ByteString - Searches for a SECOND output (different from the first) containing a DIFFERENT
token
(policyP, otherName)from the SAME policy - Swaps the tokens between the two outputs (preserving total value)
- Substitutes the redeemer ByteString with the other token's name
- Checks that the modified transaction does NOT validate
Precondition failure
If no swappable pair is found, the threat model calls failPrecondition with
a message explaining what's needed. This results in the test being SKIPPED
(not failed) because the transaction doesn't have the structure needed to
test this particular vulnerability.
To satisfy the precondition, ensure the transaction has at least two different
tokens from the same policy in different outputs. See the module documentation
for strategies to achieve this in TestingInterface.
Example: Before and After
Before swap:
Output 0: 50 ADA + 1 (PolicyX, "ValuableNFT") -- to contract owner
Output 1: 10 ADA + 1 (PolicyX, "WorthlessJunk") -- change output
Redeemer: SellRedeemer { token_name: "ValuableNFT" }
After swap:
Output 0: 50 ADA + 1 (PolicyX, "WorthlessJunk") -- swapped!
Output 1: 10 ADA + 1 (PolicyX, "ValuableNFT") -- swapped!
Redeemer: SellRedeemer { token_name: "WorthlessJunk" } -- substituted!
If the validator accepts this modified transaction, it is vulnerable because it didn't verify that "WorthlessJunk" matches what the datum specified.
Usage:
threatPrecondition $ redeemerAssetSubstitution