Platform: Code4rena
Start Date: 17/03/2023
Pot Size: $36,500 USDC
Total HM: 10
Participants: 98
Period: 3 days
Judge: leastwood
Total Solo HM: 5
Id: 223
League: ETH
Rank: 2/98
Findings: 6
Award: $4,472.49
🌟 Selected for report: 1
🚀 Solo Findings: 1
401.0269 USDC - $401.03
The fuse
function present in the Namespace contract mints a new Namespace NFT based on the given character data that references Tray tiles owned by the caller.
For each character, the implementation will use the characterToUnicodeBytes
function from the Utils
library to generate the corresponding unicode bytes for the given Tray tile data. The issue here is that the call hardcodes the _fontClass
argument to 0
instead of using the fontClass
attribute from the corresponding tile:
bytes memory charAsBytes = Utils.characterToUnicodeBytes(0, tileData.characterIndex, characterModifier);
As the call to characterToUnicodeBytes
hardcodes the _fontClass
argument to 0
, then any minted tile with a value of fontClass
different than 0 (this value ranges from 0 to 9) will be incorrectly generated.
fontClass != 0
.fuse
in the Namespace contract passing the Tray token ID and tile offset from the previous step.fuse
function will incorrectly generate the character using a value of fontClass == 0
, the Namespace NFT will be minted with an incorrect associated name.The function should call the characterToUnicodeBytes
passing the correct value tileData.fontClass
in the first argument:
bytes memory charAsBytes = Utils.characterToUnicodeBytes(tileData.fontClass, tileData.characterIndex, characterModifier);
#0 - OpenCoreCH
2023-03-29T20:54:12Z
Dup #117
#1 - c4-judge
2023-04-10T14:59:56Z
0xleastwood marked the issue as duplicate of #117
#2 - c4-judge
2023-04-11T20:46:28Z
0xleastwood marked the issue as satisfactory
#3 - c4-judge
2023-04-11T20:50:02Z
0xleastwood changed the severity to 3 (High Risk)
19.8705 USDC - $19.87
The tokenURI
function present in the Bio contract copies the biography text associated to the NFT into the "description" field while rendering the json:
string memory json = Base64.encode( bytes( string.concat( '{"name": "Bio #', LibString.toString(_id), '", "description": "', bioText, '", "image": "data:image/svg+xml;base64,', Base64.encode(bytes(string.concat(svg, text, "</text></svg>"))), '"}' ) ) ); return string(abi.encodePacked("data:application/json;base64,", json));
This is susceptible to injection as the biography text is an input supplied by the user to the minting function. A bad actor can craft a biography text to escape from the description field and insert any arbitrary data after that.
In the following test we insert an escaped double quote to break out of the description attribute, and inject a new attribute named "attribute" with a custom payload.
Note: the snippet shows only the relevant code for the test. Full test file can be found here.
function test_Bio_tokenURI_Injection() public { string memory text = "description\",\"attribute\":\"injected arbitrary data"; bio.mint(text); console.log(bio.tokenURI(1)); }
The resulting JSON has the new injected attribute:
{ "name": "Bio #1", "description": "description", "attribute":"injected arbitrary data", "image": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHByZXNlcnZlQXNwZWN0UmF0aW89InhNaW5ZTWluIG1lZXQiIHZpZXdCb3g9IjAgMCA0MDAgMTAwIj48c3R5bGU+dGV4dCB7IGZvbnQtZmFtaWx5OiBzYW5zLXNlcmlmOyBmb250LXNpemU6IDEycHg7IH08L3N0eWxlPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBkb21pbmFudC1iYXNlbGluZT0ibWlkZGxlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIj48dHNwYW4geD0iNTAlIiBkeT0iMjAiPmRlc2NyaXB0aW9uIiwiYXR0cmlidXRlIjoiaW5qZWN0ZWQgYXJiaXQ8L3RzcGFuPjx0c3BhbiB4PSI1MCUiIGR5PSIyMCI+cmFyeSBkYXRhPC90c3Bhbj48L3RleHQ+PC9zdmc+" }
The tokenURI
function should properly escape any user input, or alternatively forbid those special characters when minting the biography NFT.
#0 - c4-judge
2023-03-28T00:40:59Z
0xleastwood marked the issue as duplicate of #212
#1 - c4-judge
2023-03-28T00:41:27Z
0xleastwood marked the issue as satisfactory
#2 - c4-judge
2023-03-30T20:27:00Z
0xleastwood changed the severity to 2 (Med Risk)
679.1427 USDC - $679.14
https://github.com/code-423n4/2023-03-canto-identity/blob/main/canto-bio-protocol/src/Bio.sol#L60
The Bio tokenURI
function splits biography text into lines. Since the Bio contract supports unicode, the algorithm tries to avoid splitting the line in between bytes that represent a single unicode character.
However, the implementation fails for certain unicode characters and will insert a line break in the middle of a single character, which changes the character into others before or after the line break. See PoC for a better understanding of the issue.
In the following test, we use a series of the "👁️🗨️" character as the biography text.
Note: the snippet shows only the relevant code for the test. Full test file can be found here.
function test_Bio_tokenURI_BadLineBreak1() public { // The following text will be badly splitted string memory text = unicode"👁️🗨️👁️🗨️👁️🗨️👁️🗨️👁️🗨️👁️🗨️👁️🗨️👁️🗨️👁️🗨️👁️🗨️👁️🗨️"; bio.mint(string(text)); console.log(bio.tokenURI(1)); }
As we can see in the resulting SVG, the algorithm splitted lines in between the character, which changes it into other unicode characters:
<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMinYMin meet" viewBox="0 0 400 100"><style>text { font-family: sans-serif; font-size: 12px; }</style><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle"><tspan x="50%" dy="20">👁️🗨️👁️🗨️👁️🗨</tspan><tspan x="50%" dy="20">️👁️🗨️👁️🗨</tspan><tspan x="50%" dy="20">️👁️🗨️👁️🗨️👁</tspan><tspan x="50%" dy="20">️🗨️👁️🗨️👁️🗨</tspan><tspan x="50%" dy="20">️👁️🗨️</tspan></text></svg> `` In this other test, we use the england flag character "🏴": ```solidity function test_Bio_tokenURI_BadLineBreak2() public { // The following text will be badly splitted string memory text = unicode"🏴🏴🏴🏴🏴🏴🏴"; bio.mint(string(text)); console.log(bio.tokenURI(1)); }
Again we see how the character is changed in between line breaks in the SVG:
<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMinYMin meet" viewBox="0 0 400 100"><style>text { font-family: sans-serif; font-size: 12px; }</style><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle"><tspan x="50%" dy="20">🏴🏴</tspan><tspan x="50%" dy="20">🏴</tspan><tspan x="50%" dy="20">🏴🏴</tspan><tspan x="50%" dy="20">🏴</tspan><tspan x="50%" dy="20">🏴</tspan></text></svg>
The tokenURI
function should implement proper unicode support if its intention is to manually handle line breaks. As this is a cumbersome task, the recommendation is to avoid splitting the text (or delegate it to the SVG in case such a feature exists).
#0 - OpenCoreCH
2023-03-29T20:52:19Z
Dup #185
#1 - c4-judge
2023-04-10T14:29:24Z
0xleastwood marked the issue as duplicate of #185
#2 - c4-judge
2023-04-11T19:34:33Z
0xleastwood marked the issue as satisfactory
79.7314 USDC - $79.73
The Tray contract uses a pseudo-random algorithm to generate the tile data when a user buys and mints a new NFT. This is first defined by a seed which is the keccak256 hash of the last seed used:
lastHash = keccak256(abi.encode(lastHash)); trayTiledata[j] = _drawing(uint256(lastHash));
The _drawing
function uses this seed to generate a random value by calling the Utils.iteratePRNG
function. This random value is then used to calculate the tile data based on range of 0 to SUM_ODDS
values:
function _drawing(uint256 _seed) private pure returns (TileData memory tileData) { uint256 res = _seed % SUM_ODDS; uint256 charRandValue = Utils.iteratePRNG(_seed); // Iterate PRNG to not have any biasedness / correlation between random numbers if (res < 32) { // Class is 0 in that case tileData.characterIndex = uint16(charRandValue % NUM_CHARS_EMOJIS); } else { tileData.characterIndex = uint16(charRandValue % NUM_CHARS_LETTERS); if (res < 64) { tileData.fontClass = 1; tileData.characterIndex = uint16(charRandValue % NUM_CHARS_LETTERS_NUMBERS); } else if (res < 80) { tileData.fontClass = 2; } else if (res < 96) { tileData.fontClass = 3 + uint8((res - 80) / 8); } else if (res < 104) { tileData.fontClass = 5 + uint8((res - 96) / 4); } else if (res < 108) { tileData.fontClass = 7 + uint8((res - 104) / 2); if (tileData.fontClass == 7) { // Set seed for Zalgo to ensure same characters will be always generated for this tile uint256 zalgoSeed = Utils.iteratePRNG(_seed); tileData.characterModifier = uint8(zalgoSeed % 256); } } else { tileData.fontClass = 9; } } }
As this data is stored completely on-chain, any user can anticipate what tiles they will get when buying a Tray NFT:
lastHash
.lastHash
to get the next seed._drawing
to calculate beforehand what NFT they will get.While others mint NFTs, the buyer can then wait until the conditions are in place to mint the NFT they desire or mint a rare or more valuable NFT.
Note that this also allows the buyer to anticipate the data of any number of tiles, not just the next one. If the proximity is enough to justify the costs, the buyer can also buy NFTs in bulk (the Tray contract is an ERC721A) to ensure the NFT they want.
In this test we show how a buyer can easily anticipate the data for the next tile to be minted.
Note: the snippet shows only the relevant code for the test. Full test file can be found here.
function test_Tray_buy_AnticipateNFTData() public { tray.endPrelaunchPhase(); vm.startPrank(buyer); bytes32 currentHash = tray.lastHash(); bytes32 nextHash = keccak256(abi.encode(currentHash)); Tray.TileData memory predictedTileData = simulateDrawing(uint256(nextHash)); tray.buy(1); Tray.TileData memory tileData = tray.getTile(1, 0); assertEq(tileData.fontClass, predictedTileData.fontClass); assertEq(tileData.characterIndex, predictedTileData.characterIndex); assertEq(tileData.characterModifier, predictedTileData.characterModifier); vm.stopPrank(); } function simulateDrawing(uint256 _seed) internal pure returns (Tray.TileData memory tileData) { uint256 res = _seed % SUM_ODDS; uint256 charRandValue = Utils.iteratePRNG(_seed); // Iterate PRNG to not have any biasedness / correlation between random numbers if (res < 32) { // Class is 0 in that case tileData.characterIndex = uint16(charRandValue % NUM_CHARS_EMOJIS); } else { tileData.characterIndex = uint16(charRandValue % NUM_CHARS_LETTERS); if (res < 64) { tileData.fontClass = 1; tileData.characterIndex = uint16(charRandValue % NUM_CHARS_LETTERS_NUMBERS); } else if (res < 80) { tileData.fontClass = 2; } else if (res < 96) { tileData.fontClass = 3 + uint8((res - 80) / 8); } else if (res < 104) { tileData.fontClass = 5 + uint8((res - 96) / 4); } else if (res < 108) { tileData.fontClass = 7 + uint8((res - 104) / 2); if (tileData.fontClass == 7) { // Set seed for Zalgo to ensure same characters will be always generated for this tile uint256 zalgoSeed = Utils.iteratePRNG(_seed); tileData.characterModifier = uint8(zalgoSeed % 256); } } else { tileData.fontClass = 9; } } }
The Tray contract shouldn't rely on a pseudo-random algorithm to generate tile data as this can be easily calculated beforehand. Consider using a VRF or an external oracle that provides stronger random guarantees.
#0 - c4-judge
2023-03-28T18:45:53Z
0xleastwood marked the issue as primary issue
#1 - OpenCoreCH
2023-03-29T19:51:01Z
It's a feature, not a bug™ I assumed that this might be confusing, so I added in the README:
The 7 tiles per tray are then generated according to a deterministic algorithm. A user can therefore precompute which trays he will get.
But maybe that should have been more prominent.
As the warden mentions, this introduces an interesting tradeoff in practice. If your desired Tray is 100 mints away, do you buy now and pay for all 100? Or do you wait, potentially risking that someone else buys it in between?
#2 - c4-sponsor
2023-03-29T19:51:15Z
OpenCoreCH marked the issue as sponsor disputed
#3 - 0xleastwood
2023-03-30T20:38:18Z
Should this then be considered out of scope if it was already outlined in the README? Is this mechanism intended be some gameable by users?
#4 - OpenCoreCH
2023-03-30T20:57:15Z
Should this then be considered out of scope if it was already outlined in the README? Is this mechanism intended be some gameable by users?
I'd say so because it's intended behaviour that was explicitly stated. Yeah, I mean you can in theory wait for your desired character and try to frontrun the call, but that's not a risk-free strategy. Someone else might submit a TX in a private mempool before that (with a high quantity), meaning you did do not get the desired character.
#5 - 0xleastwood
2023-04-10T13:28:06Z
To be fair, the use of a deterministic algorithm was already stated in the docs, but I do still consider the concerns to be valid and will opt to downgrade to medium severity. This seems like a pain point that should probably be addressed.
#6 - c4-judge
2023-04-10T13:28:27Z
0xleastwood changed the severity to 2 (Med Risk)
#7 - c4-judge
2023-04-11T19:54:57Z
0xleastwood marked issue #244 as primary and marked this issue as a duplicate of 244
#8 - c4-judge
2023-04-11T20:02:55Z
0xleastwood marked the issue as not a duplicate
#9 - c4-judge
2023-04-11T20:03:00Z
0xleastwood marked the issue as primary issue
#10 - c4-judge
2023-04-11T20:03:30Z
0xleastwood marked the issue as selected for report
#11 - c4-judge
2023-04-12T00:55:07Z
0xleastwood marked the issue as duplicate of #130
#12 - c4-judge
2023-04-12T01:00:07Z
0xleastwood marked the issue as partial-50
#13 - c4-judge
2023-04-12T01:00:28Z
0xleastwood marked the issue as not a duplicate
#14 - c4-judge
2023-04-12T01:00:32Z
0xleastwood marked the issue as satisfactory
#15 - c4-judge
2023-04-12T01:00:39Z
0xleastwood marked the issue as duplicate of #130
#16 - c4-judge
2023-04-12T01:01:24Z
0xleastwood marked the issue as not selected for report
🌟 Selected for report: adriro
3269.9461 USDC - $3,269.95
The Bio tokenURI
function splits biography text into lines. The algorithm will take into account certain "continuation" characters to prevent splitting the line in the middle of these characters and keep accumulating those in the current line buffer (bytesLines
):
if ((i > 0 && (i + 1) % 40 == 0) || prevByteWasContinuation || i == lengthInBytes - 1) { bytes1 nextCharacter; if (i != lengthInBytes - 1) { nextCharacter = bioTextBytes[i + 1]; } if (nextCharacter & 0xC0 == 0x80) { // Unicode continuation byte, top two bits are 10 prevByteWasContinuation = true; } else { // Do not split when the prev. or next character is a zero width joiner. Otherwise, 👨👧👦 could become 👨>👧👦 // Furthermore, do not split when next character is skin tone modifier to avoid 🤦♂️\n🏻 if ( // Note that we do not need to check i < lengthInBytes - 4, because we assume that it's a valid UTF8 string and these prefixes imply that another byte follows (nextCharacter == 0xE2 && bioTextBytes[i + 2] == 0x80 && bioTextBytes[i + 3] == 0x8D) || (nextCharacter == 0xF0 && bioTextBytes[i + 2] == 0x9F && bioTextBytes[i + 3] == 0x8F && uint8(bioTextBytes[i + 4]) >= 187 && uint8(bioTextBytes[i + 4]) <= 191) || (i >= 2 && bioTextBytes[i - 2] == 0xE2 && bioTextBytes[i - 1] == 0x80 && bioTextBytes[i] == 0x8D) ) { prevByteWasContinuation = true; continue; } assembly { mstore(bytesLines, bytesOffset) } strLines[insertedLines++] = string(bytesLines); bytesLines = new bytes(80); prevByteWasContinuation = false; bytesOffset = 0; } }
However, if the unicode string is a sequence of these continuation characters (which is a valid UTF8 string) the line buffer (which is 80 bytes) will eventually overflow and will revert the transaction due to an index of out bounds exception.
In the following test we use a string with 21 U+1F3FE characters to overflow the line buffer and revert the query to tokenURI
.
Note: the snippet shows only the relevant code for the test. Full test file can be found here.
function test_Bio_tokenURI_LineBufferOverflow() public { // This is a skin tone codepoint ("f09f8fbe") repeated 21 times string memory text = unicode"🏾🏾🏾🏾🏾🏾🏾🏾🏾🏾🏾🏾🏾🏾🏾🏾🏾🏾🏾🏾🏾"; bio.mint(string(text)); vm.expectRevert(); bio.tokenURI(1); }
Change to a new line when the current line buffer is full.
#0 - c4-sponsor
2023-03-29T20:55:22Z
OpenCoreCH marked the issue as sponsor confirmed
#1 - OpenCoreCH
2023-03-29T20:55:49Z
Extreme edge case with a string that's technically valid, but semantically meaningless. But it's technically true.
#2 - c4-judge
2023-04-10T20:23:55Z
0xleastwood marked the issue as primary issue
#3 - c4-judge
2023-04-11T19:55:31Z
0xleastwood marked the issue as selected for report
🌟 Selected for report: Sathish9098
Also found by: 0xAgro, 0xSmartContract, 0xdaydream, 0xnev, Awesome, Aymen0909, BRONZEDISC, Bauchibred, Deathstore, Diana, IceBear, Jerry0x, Kresh, Matin, Rolezn, Stryder, T1MOH, Udsen, adriro, alejandrocovrr, atharvasama, codeslide, cryptonue, descharre, igingu, jack, joestakey, libratus, lukris02, luxartvinsec, nadin, nasri136, reassor, scokaf, shark, slvDev, tnevler
22.7749 USDC - $22.77
Issue | Instances | |
---|---|---|
NC-1 | Import declarations should import specific symbols | 10 |
NC-2 | Use named parameters for mapping type declarations | 6 |
NC-3 | Use uint256 instead of the uint alias | 5 |
NC-4 | Use constants for literal config values | 27 |
Prefer import declarations that specify the symbol(s) using the form import {SYMBOL} from "SomeContract.sol"
rather than importing the whole file
Instances (10):
File: canto-bio-protocol/src/Bio.sol 7: import "../interface/Turnstile.sol"; 8: import "forge-std/Test.sol";
File: canto-namespace-protocol/src/Namespace.sol 7: import "./Tray.sol"; 8: import "./Utils.sol"; 9: import "../interface/Turnstile.sol";
File: canto-namespace-protocol/src/Tray.sol 10: import "./Utils.sol"; 11: import "../interface/Turnstile.sol";
File: canto-namespace-protocol/src/Utils.sol 4: import "./Tray.sol";
File: canto-pfp-protocol/src/ProfilePicture.sol 5: import "../interface/Turnstile.sol"; 6: import "../interface/ICidNFT.sol";
Consider using named parameters in mappings (e.g. mapping(address account => uint256 balance)
) to improve readability. This feature is present since Solidity 0.8.18
Instances (6):
File: canto-bio-protocol/src/Bio.sol 19: mapping(uint256 => string) public bio;
File: canto-namespace-protocol/src/Namespace.sol 43: mapping(string => uint256) public nameToToken; 46: mapping(uint256 => string) public tokenToName; 49: mapping(uint256 => Tray.TileData[]) private nftCharacters;
File: canto-namespace-protocol/src/Tray.sol 67: mapping(uint256 => TileData[TILES_PER_TRAY]) private tiles;
File: canto-pfp-protocol/src/ProfilePicture.sol 32: mapping(uint256 => ProfilePictureData) private pfp;
uint256
instead of the uint
aliasPrefer using the uint256
type definition over its uint
alias.
Instances (5):
File: canto-bio-protocol/src/Bio.sol 48: uint lengthInBytes = bioTextBytes.length; 50: uint lines = (lengthInBytes - 1) / 40 + 1; 56: uint bytesOffset; 57: for (uint i; i < lengthInBytes; ++i) { 115: text = string.concat(text, '<tspan x="50%" dy="20">', strLines[i], "</tspan>");
Instances (27):
Issue | Instances | |
---|---|---|
L-1 | Contract files should define a locked compiler version | 4 |
L-2 | Validate revenue addresses are not address(0) | 4 |
L-3 | Namespace burn should delete nftCharacters variable | - |
Contracts should be deployed with the same compiler version and flags that they have been tested with thoroughly. Locking the pragma helps to ensure that contracts do not accidentally get deployed using, for example, an outdated compiler version that might introduce bugs that affect the contract system negatively.
Instances (4):
File: canto-bio-protocol/src/Bio.sol 2: pragma solidity >=0.8.0;
File: canto-namespace-protocol/src/Namespace.sol 2: pragma solidity >=0.8.0;
File: canto-namespace-protocol/src/Tray.sol 2: pragma solidity >=0.8.0;
File: canto-pfp-protocol/src/ProfilePicture.sol 2: pragma solidity >=0.8.0;
address(0)
Check that configured addresses are not address(0)
, else fees will be lost.
Instances (4):
burn
should delete nftCharacters
variableThe burn
function present in the Namespace contract should also delete and clear the associated data in the nftCharacters
variable.
function burn(uint256 _id) external { address nftOwner = ownerOf(_id); if (nftOwner != msg.sender && getApproved[_id] != msg.sender && !isApprovedForAll[nftOwner][msg.sender]) revert CallerNotAllowedToBurn(); string memory associatedName = tokenToName[_id]; delete tokenToName[_id]; delete nameToToken[associatedName]; delete nftCharacters[_id]; _burn(_id); }
#0 - c4-judge
2023-04-11T16:15:09Z
0xleastwood marked the issue as grade-b