Prepared by:
HALBORN
Last Updated 10/15/2024
Date of Engagement by: June 21st, 2024 - July 5th, 2024
100% of all REPORTED Findings have been addressed
All findings
5
Critical
2
High
0
Medium
0
Low
1
Informational
2
Concrete
engaged Halborn to conduct a security assessment on their smart contract beginning on June 21st, 2024 and ending on July 5th, 2024. A security assessment on smart contracts was performed on the scoped smart contracts provided to the Halborn team.
The team at Halborn was provided two weeks for the engagement and assigned a full-time security engineer to verify the security of the smart contract. The security engineer is a blockchain and smart-contract security expert with advanced penetration testing, smart-contract hacking, and deep knowledge of multiple blockchain protocols.
The purpose of this assessment is to:
Ensure that smart contract functions operate as intended.
Identify potential security issues with the smart contracts.
In summary, Halborn identified some security risks that were successfully addressed by the Concrete team
.
Halborn performed a combination of manual and automated security testing to balance efficiency, timeliness, practicality, and accuracy in regard to the scope of this assessment. While manual testing is recommended to uncover flaws in logic, process, and implementation; automated testing techniques help enhance coverage of the code and can quickly identify items that do not follow the security best practices. The following phases and associated tools were used during the assessment:
Research into architecture and purpose.
Smart contract manual code review and walkthrough.
Graphing out functionality and contract logic/connectivity/functions (solgraph).
Manual assessment of use and safety for the critical Solidity variables and functions in scope to identify any arithmetic related vulnerability classes.
Manual testing by custom scripts.
Testnet deployment (Foundry).
External libraries and financial-related attacks.
EXPLOITABILIY METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Attack Origin (AO) | Arbitrary (AO:A) Specific (AO:S) | 1 0.2 |
Attack Cost (AC) | Low (AC:L) Medium (AC:M) High (AC:H) | 1 0.67 0.33 |
Attack Complexity (AX) | Low (AX:L) Medium (AX:M) High (AX:H) | 1 0.67 0.33 |
IMPACT METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Confidentiality (C) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Integrity (I) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Availability (A) | None (A:N) Low (A:L) Medium (A:M) High (A:H) Critical (A:C) | 0 0.25 0.5 0.75 1 |
Deposit (D) | None (D:N) Low (D:L) Medium (D:M) High (D:H) Critical (D:C) | 0 0.25 0.5 0.75 1 |
Yield (Y) | None (Y:N) Low (Y:L) Medium (Y:M) High (Y:H) Critical (Y:C) | 0 0.25 0.5 0.75 1 |
SEVERITY COEFFICIENT () | COEFFICIENT VALUE | NUMERICAL VALUE |
---|---|---|
Reversibility () | None (R:N) Partial (R:P) Full (R:F) | 1 0.5 0.25 |
Scope () | Changed (S:C) Unchanged (S:U) | 1.25 1 |
Severity | Score Value Range |
---|---|
Critical | 9 - 10 |
High | 7 - 8.9 |
Medium | 4.5 - 6.9 |
Low | 2 - 4.4 |
Informational | 0 - 1.9 |
Critical
2
High
0
Medium
0
Low
1
Informational
2
Security analysis | Risk level | Remediation Date |
---|---|---|
Packets Error Handling: EVM revert in ABI Decoding | Critical | Solved - 07/01/2024 |
Incorrect Fragment Count Leading to External Call Error | Critical | Solved - 07/01/2024 |
Lack of Packet Size Limitation | Low | Solved - 07/31/2024 |
Missing nonReentrant modifier | Informational | Solved - 08/01/2024 |
No Events for State Changes | Informational | Solved - 07/27/2024 |
// Critical
An EVM error is encountered at the following line in the Solidity code: (address target, bytes memory data) = abi.decode(modifiedPacket, (address, bytes));
This error occurs because modifiedPacket
does not match the expected encoding for (address, bytes)
.
function multipleReceivePseudoInternal(
uint8 numFrgmt_,
uint8 lastFrgmtCnt_,
bytes memory instructions_,
bytes[] memory packets_, // NOTE: Only the packets for this bundle
bytes[] memory responses_, // all the responses. // the ones that have not yet been generated are empty
bytes32 successFlags_
) external OnlyPseudoInternalCalls returns (bytes[] memory responses) {
bool callSuccess;
uint16 startFrom;
// bool inputFromRevertedCallFlag;
bytes[] memory newResponses = new bytes[](numFrgmt_);
bytes memory modifiedPacket;
for (uint256 i = 0; i < numFrgmt_; i++) {
(callSuccess, startFrom, modifiedPacket) =
packets_[i].modifyPacket(responses_, successFlags_, instructions_, startFrom);
if (!callSuccess) {
revert EndpointErrors.CannotInsertInput(lastFrgmtCnt_ + uint8(i));
}
(address target, bytes memory data) = abi.decode(modifiedPacket, (address, bytes));
// This external call should just execute the calldata that is passed to it. If that call data is a zero
// address, then so it should be.
// slither-disable-next-line missing-zero-check
(callSuccess, newResponses[i]) = target.call(data);
if (!callSuccess) {
revert EndpointErrors.OneCallInBatchedCallReverted(lastFrgmtCnt_ + uint8(i));
}
}
return newResponses;
}
1.Analysis of the Issue
To understand why this error is triggered, it's essential to break down the expected structure of modifiedPacket
for the abi.decode
function to work correctly. The expected structure for (address, bytes)
should be:
1. Address: An Ethereum address, which is 20 bytes long.
2. Bytes: A dynamically sized bytes type that includes a 32-byte length prefix followed by the actual data.
Expected Structure
For abi.decode
to function correctly, modifiedPacket
should be structured as follows:
- The first 20 bytes represent the address.
- The next 32 bytes represent the length of the bytes array.
- The remaining bytes represent the actual data.
Error Triggering Example
Given the modifiedPacket
value of 0x0001000000002000188101000000200018
, which is only 20 bytes long, the structure appears as follows: 0x0001000000002000188101000000200018
This 20-byte modifiedPacket
is insufficient for abi.decode
to extract both an address and a bytes array. Consequently, the decoding operation fails, triggering an EVM error revert.
2.Root Cause
The root cause is that modifiedPacket
contains only 20 bytes, which does not meet the requirements for decoding into (address, bytes)
. Furthermore, even if abi.decode
could decode the bytes into data, the target address would be incorrect. The value 0x0001000000002000188101000000200018
represents instructions from packets_[0]
, not a valid Ethereum address.
Conclusion
The critical issue lies in the incorrect structure of modifiedPacket
, which does not conform to the expected (address, bytes)
format. This mismatch leads to an EVM error during the abi.decode
operation. Moreover, the modifiedPacket
is the first packets_[0]
corresponding to the instructions instead of the target address and bytes data. Finally, it was identified the inability to execute incoming packets with instructions (from Concrete Backend), incremented the severity of this issue. Furthermore, this failure was attributed to errors during MultiResponse
emission, which resulted in the unsuccessful execution of an external call due to improper packet handling.
The following Foundry
test was used in order to prove the aforementioned issue:
function test_cccmMultiReceiveWithInstructions() public {
bytes[] memory packets = new bytes[](4);
packets[0] = abi.encodePacked(
bytes1(0x00), // 00000000 => flag = 0 and length = 0
bytes1(0x01), // 00000001 => flag = 0 and length = 1
hex"00", // index of the first response
hex"0000", // start at the beginning of the response
hex"0020", // length of the cropped response (= 32 bytes)
hex"0018", // byte where the cropped response data should be inserted (24 = 20 + 4 = 0x18)
bytes1(0x81), // 10000001 => flag = 1 and length = 1
hex"01", // index of the first response
hex"0000", // start at the beginning of the response
hex"0020", // length of the cropped response
hex"0018" // byte where the cropped response data should be inserted
);
// bytes responses[] = new bytes[](3);
// read the instruction for the first packet. There are none. But the flag is 0, so this is NOT the last packet in the bundle.
// (bool success, bytes responses[0]) = address(packets[1][0:20]).call(packets[1][20:]])
// read the instruction for the second packet. There is one. The flag is again 0, so it is NOT the last packet in the bundle. The instructions says: take the response from the first packet (i.e. index 0 = hex"00"), then crop it from the 0th byte (0x00 = 0) and take 32 bytes (0x20 = 32), in other words that would be the entire response (i.e. uint256(B) = 33).
// and paste it into the calldata. I.e. into packets[2], like so:
// modifiedPacket = packets[2][0:24] + responses[0][0:32] + packets[2][24 + 32:] (actually the last part is empty, because the packet[2] only has 56 bytes = address + selector + uint256)
// NOTE that the original data that was in the call data is now lost. So the number 6 is lost.
// (bool success, bytes responses[1]) = address(modifiedPacket[0:20]).call(modifiedPacket[20:]])
// read the instruction for the third packet. There is one. The flag is 1, so it is the lat packet in the bundle.
// The instructions says: take the response from the first packet (i.e. index 1 = hex"01"), then crop it from byte 0 (0x00 = 0) and take 32 bytes (0x20 = 32), in other words that would be the entire responses[1] (i.e. uint256(A + B) = 45). And paste it into the calldata. I.e. into packets[3], like so:
// modifiedPacket = packets[3][0:24] + responses[1][0:32] + packets[3][24 + 32:] (actually the last part is empty, because the packet[3] only has 56 bytes = address + selector + uint256)
// NOTE that the original data that was in the call data is now lost. So the number 0 is lost.
// (bool success, bytes responses[2]) = address(modifiedPacket[0:20]).call(modifiedPacket[20:]])
packets[1] = abi.encodePacked(address(mockCallContract), MockCallContract.getB.selector); // call number = 0 => response index = 0
packets[2] = abi.encodeWithSelector(MockCallContract.addToA.selector, 6);
packets[3] = abi.encodeWithSelector(MockCallContract.addToA.selector, 0);console.log("PACKET0: ",address(bytes20(packets[0])));console.log("PACKET1: ",address(bytes20(packets[1])));console.log("PACKET2: ",address(bytes20(packets[2])));console.log("PACKET3: ",address(bytes20(packets[3])));
// expected responses
bytes[] memory responses = new bytes[](3);
responses[0] = abi.encode(B);
responses[1] = abi.encode(A + B);
responses[2] = abi.encode(2 * (A + B));
// no response template
bytes memory responseTemplate = "";
// send the packets without instructions and expect event to be emitted
vm.startPrank(executor);
uint224 nonce = uint224(multiMessaging.getNonce());
bytes32 packetId = bytes32((uint256(nonce) << 32) | uint256(targetEid));
// check if the event was emitted
//vm.expectEmit(true, false, false, false, address(multiMessaging));
//emit EndpointMultiMessageEvents.MultiResponse(packetId, keccak256(abi.encode(packets)), 3, responses);
multiMessaging.cccmMultiReceive(packetId, eid, packets, responseTemplate);
vm.stopPrank();
}
Evidence (EVM: Error revert):
Consider solving the structure of modifiedPacket
and ensuring it contains valid target addresses and data is crucial to resolving this issue.
SOLVED : The Concrete team solved the issue by extracting the calldata
from a packet and calls the target address with it properly.
// Critical
It has been observed that the extractSubset
function is using an incorrect temp.lastFrgmtCnt
value, set as 0x0001000000002000188101000000200018
.
// insert the instructions and make a low level call.
(bool callSuccess, bytes memory newResponsesEncoded) = address(this).call(
abi.encodeWithSelector(
this.multipleReceivePseudoInternal.selector,
numFrgmt,
temp.lastFrgmtCnt, // last fragment from the previous bundle
packets_[0].sliceRevertOnInvalid(temp.startByte, endByte), // relevant part of instructions
CccmLib.extractSubset(packets_, temp.lastFrgmtCnt, numFrgmt),
responses,
successFlags
)
);
Here, temp.lastFrgmtCnt
is incorrectly selecting packets_[0]
, which corresponds to the instructions. As a result, modifiedPacket
is set incorrectly to the hexadecimal values of the instructions, rather than targeting the appropriate packet for performing the external call on the multipleReceivePseudoInternal
function.
The following Foundry
test was used in order to prove the aforementioned issue:
function test_cccmMultiReceiveWithInstructions() public {
mockCallContract.createSecondMockCallContract();
MockCallContractSimple secondMockCallContract = MockCallContractSimple(mockCallContract.getSecondMockAddress());
bytes[] memory packets = new bytes[](5);
packets[0] = abi.encodePacked(
bytes1(0x00), // 00000000 => flag = 0 and length = 0. First packet.
bytes1(0x81), // 00000001 => flag = 0 and length = 1. Second packet. Takes de response from first packet.
hex"00", // index of the first response
hex"0000", // start at the beginning of the response
hex"0020", // length of the cropped response (= 32 bytes)
hex"0018", // byte where the cropped response data should be inserted (24 = 20 + 4 = 0x18)
bytes1(0x81), // 10000001 => flag = 1 and length = 1.
hex"01", // index of the second response
hex"0000", // start at the beginning of the response
hex"0020", // length of the cropped response
hex"0018", // byte where the cropped response data should be inserted
bytes1(0x82), // 10000010 => flag = 0 and length = 2
hex"02", // index of the third response (i.e. the address of the mock contract)
hex"000c", // start at the beginning of the response
hex"0014", // length of the cropped response (length of an address, i.e. "0x14"=20 bytes)
hex"0000", // byte where the cropped response data should be inserted
hex"01", // index of the second response
hex"0000", // start at the beginning of the response
hex"0020", // length of the cropped response
hex"0018" // byte where the cropped response data should be inserted
);
// bytes responses[] = new bytes[](4);
// read the instruction for the first packet. There are none. But the flag is 0, so this is NOT the last packet in the bundle.
// (bool success, bytes responses[0]) = address(packets[1][0:20]).call(packets[1][20:]])
// read the instruction for the second packet. There is one. The flag is again 1, so it is INDEED the last packet in the bundle (first bundle). The instructions says: take the response from the first packet (i.e. index 0 = hex"00"), then crop it from the 0th byte (0x00 = 0) and take 32 bytes (0x20 = 32), in other words that would be the entire response (i.e. uint256(B) = 33).
// and paste it into the calldata. I.e. into packets[2], like so:
// modifiedPacket = packets[2][0:24] + responses[0][0:32] + packets[2][24 + 32:] (actually the last part is empty, because the packet[2] only has 56 bytes = address + selector + uint256)
// NOTE that the original data that was in the call data is now lost. So the number 6 is lost.
// (bool success, bytes responses[1]) = address(modifiedPacket[0:20]).call(modifiedPacket[20:]])
// read the instruction for the third packet. There is one. The flag is 0, so it is NOT the last packet in the bundle.
// The instructions says: take the response from the first packet (i.e. index 1 = hex"01"), then crop it from byte 0 (0x00 = 0) and take 32 bytes (0x20 = 32), in other words that would be the entire responses[1] (i.e. uint256(A + B) = 45). And paste it into the calldata. I.e. into packets[3], like so:
// modifiedPacket = packets[3][0:24] + responses[1][0:32] + packets[3][24 + 32:] (actually the last part is empty, because the packet[3] only has 56 bytes = address + selector + uint256)
// NOTE that the original data that was in the call data is now lost. So the number 0 is lost.
// (bool success, bytes responses[2]) = address(modifiedPacket[0:20]).call(modifiedPacket[20:]])
// read the instruction for the fourth packet. There are two. The flag is 1, so it is INDEED the last packet in the bundle. The second bundle.
// The first instruction says take the response from the third packet (i.e. index 2 = hex"02"), then crop it from byte 0 (0x00 = 0) and take 20 bytes (0x14 = 20), in other words that would be the entire address of the second mock contract. And paste it into the calldata. I.e. into packets[4], like so:
// modifiedPacket = responses[2][0:20] + packets[4][20:]
// Then take the response from the second packet (i.e. index 1 = hex"01"), then crop it from byte 0 (0x00 = 0) and take 32 bytes (0x20 = 32), in other words that would be the entire responses[1] (i.e. uint256(A + B) = 45). And paste it into the calldata. I.e. into packets[4], like so:
// modifiedPacket = modifiedPacket[0:20] + responses[1][0:32] + packets[4][20 + 32:] (actually the last part is empty, because the packet[4] only has 52 bytes = address + selector + uint256)
packets[1] = abi.encodePacked(address(mockCallContract), MockCallContract.getB.selector); // call number = 0 => response index = 0
packets[2] = abi.encodePacked(address(mockCallContract), MockCallContract.addToA.selector, uint256(6));
packets[3] = abi.encodePacked(address(mockCallContract), MockCallContract.getAddress.selector, uint256(0));
packets[4] = abi.encodePacked(address(mockCallContract), MockCallContract.addToA.selector, uint256(6));
// expected responses
bytes[] memory responses = new bytes[](4);
responses[0] = abi.encode(B);
responses[1] = abi.encode(A + B);
responses[2] = abi.encode(address(secondMockCallContract));
responses[3] = abi.encode(A + B);
// no response template
bytes memory responseTemplate = "";
// send the packets without instructions and expect event to be emitted
vm.startPrank(executor);
uint224 nonce = uint224(multiMessaging.getNonce());
bytes32 packetId = bytes32((uint256(nonce) << 32) | uint256(targetEid));
// create a uint256 from the binary format 001111
uint256 successFlags = 1 + 2 + 4 + 8;
// check if the event was emitted
vm.expectEmit(true, true, true, true, address(multiMessaging));
emit EndpointMultiMessageEvents.MultiResponse(packetId, keccak256(abi.encode(packets)), successFlags, responses);
multiMessaging.cccmMultiReceive(packetId, eid, packets, responseTemplate);
vm.stopPrank();
}
Evidence:
Consider incrementing one to temp.lastFrgmtCnt
value in order to use the target address once it decoded instead of instructions.
// insert the instructions and make a low level call.
(bool callSuccess, bytes memory newResponsesEncoded) = address(this).call(
abi.encodeWithSelector(
this.multipleReceivePseudoInternal.selector,
numFrgmt,
temp.lastFrgmtCnt, // last fragment from the previous bundle
packets_[0].sliceRevertOnInvalid(temp.startByte, endByte), // relevant part of instructions
CccmLib.extractSubset(packets_, temp.lastFrgmtCnt +1, numFrgmt),
responses,
successFlags
)
);
SOLVED : The Concrete team solved the issue. They incremented to one the temp.lastFrgmtCnt
variable in order to select the proper value for executing the external call.
// Low
It has been identified that there is no restriction on the size of the packets being formed, allowing for the encoding of excessively large packets. This lack of limitation can lead to unexpected behaviors and potential Denial of Service (DoS) attacks when attempting to iterate over packets_.length
before making external calls.
function multipleReceiveOneBatchPseudoInternal(bytes[] calldata packets_)
external
OnlyPseudoInternalCalls
returns (bytes[] memory responses)
{
bool callSuccess;
responses = new bytes[](packets_.length - 1);
for (uint256 i = 1; i < packets_.length; i++) {
(callSuccess, responses[i - 1]) = address(bytes20(packets_[i][:20])).call(packets_[i][20:]);
if (!callSuccess) {
revert EndpointErrors.OneCallInBatchedCallReverted(uint8(i - 1));
}
}
}
Unexpected Behaviors: The function may exhibit unanticipated actions due to the lack of packet size control.
Denial of Service (DoS): Iterating over large packet arrays can lead to significant delays or failures, impacting the system's reliability and availability.
The following Foundry
test was used in order to prove the aforementioned issue:
function test_cccmMultiReceiveWithOutInstructions_TooMany() public {
uint256 C = 25;
// generate two calldata packets. One is for retrieving the B value an
// the second is for addToA some value
bytes[] memory packets = new bytes[](10000);
// ["", packet1, packet2]
packets[0] = "";
packets[1] = abi.encodePacked(address(mockCallContract), MockCallContract.getB.selector);
packets[2] = abi.encodePacked(address(mockCallContract), MockCallContract.addToA.selector, C);
for (uint256 index = 3; index < 10000; index++) {
packets[index] = abi.encodePacked(address(mockCallContract), MockCallContract.addToA.selector, C);
}
// expected responses
bytes[] memory responses = new bytes[](2);
responses[0] = abi.encode(B);
responses[1] = abi.encode(A + C);
// no response template
bytes memory responseTemplate = "";
// send the packets without instructions and expect event to be emitted
vm.startPrank(executor);
uint224 nonce = uint224(multiMessaging.getNonce());
bytes32 packetId = bytes32((uint256(nonce) << 32) | uint256(targetEid));
// check if the event was emitted
vm.expectEmit(true, true, true, true, address(multiMessaging));
emit EndpointMultiMessageEvents.MultiResponse(packetId, keccak256(abi.encode(packets)), 3, responses);
multiMessaging.cccmMultiReceive(packetId, eid, packets, responseTemplate);
vm.stopPrank();
}
Evidence: Allowed executed a packet of 10000 size, and the huge event emitting with all responses array.
Consider implementing a restriction on the maximum allowable size of packets to prevent encoding excessively large packets. Moreover, validate packet sizes before processing to ensure they fall within acceptable limits.
SOLVED: The suggested mitigation was applied to the following functions:
MultiMessaging::cccmMultiSend()
MultiMessaging::cccmMultiReceive()
SingleMessaging::cccmSingleSend()
SingleMessaging::cccmSingleReceive()
// Informational
In the MultiMessaging
and SingleMessaging
contract, the function "Receive" involves an external call to the target packets.
While the contract's current implementation presents no obvious vulnerability due to the fact that the external call is made by the executor (Role guard), it is advisable to include the nonReentrant
modifier for enhanced security and to prevent unexpected behavior. This is a safer practice and can prevent potential issues in future updates or unforeseen scenarios.
Add a nonReentrant
type modifier to the function to further enhance security.
SOLVED: A modified reentrancy guard was added, which prevents smart contracts from reentering.
modifier nonReentrantForContracts() {
if (msg.sender == tx.origin) {
_;
} else {
_nonReentrantBefore();
_;
_nonReentrantAfter();
}
}
// Informational
Adding events to the setEid
and setNativeTokenRecipient
functions would provide better traceability of state changes.
Consider defining and emit events for these functions.
event EidSet(uint32 indexed newEid);
event NativeTokenRecipientSet(address indexed newRecipient);
function setEid(uint32 eid_) external override(IEid) onlyRole(EndpointRoles.SETTER) {
if (eid_ == 0) revert EndpointErrors.InvalidTargetEid(eid_);
EID = eid_;
emit EidSet(eid_);
}
function setNativeTokenRecipient(address payable recipient_) external override(ISendNativeToken) onlyRole(EndpointRoles.MANAGER) {
if (recipient_ == address(0)) revert EndpointErrors.InvalidNativeTokenRecipientAddress();
_nativeTokenRecipient = recipient_;
emit NativeTokenRecipientSet(recipient_);
}
SOLVED: The suggested mitigation was implemented by the Concrete team.
Halborn used automated testing techniques to enhance the coverage of certain areas of the scoped contracts. Among the tools used was Slither, a Solidity static analysis framework. After Halborn verified all the contracts in the repository and was able to compile them correctly into their ABI and binary formats, Slither was run on the all-scoped contracts. This tool can statically verify mathematical relationships between Solidity variables to detect invalid or inconsistent usage of the contracts' APIs across the entire code-base. Some High and Medium issues have been found using automated testing that may be it's important to be considered:
Output:
Halborn strongly recommends conducting a follow-up assessment of the project either within six months or immediately following any material changes to the codebase, whichever comes first. This approach is crucial for maintaining the project’s integrity and addressing potential vulnerabilities introduced by code modifications.
// Download the full report
* Use Google Chrome for best results
** Check "Background Graphics" in the print settings if needed