Prepared by:
HALBORN
Last Updated 10/15/2024
Date of Engagement by: August 12th, 2024 - September 10th, 2024
100% of all REPORTED Findings have been addressed
All findings
5
Critical
2
High
1
Medium
0
Low
1
Informational
1
Concrete
engaged Halborn
to conduct a security assessment on their smart contracts related to Hub/Spokes libraries
beginning on 2024-08-12 and ending on 2024-09-10. The security assessment was scoped to the smart contracts provided in directly be the team.
Commit hashes and further details can be found in the Scope section of this report.
Halborn
was provided four weeks for the engagement and assigned one full-time security engineer to check 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 several security concerns that were mostly addressed by the Concrete team.
Halborn
performed a combination of manual review of the code and automated security testing to balance efficiency, timeliness, practicality, and accuracy in regard to the scope of the smart contract assessment. While manual testing is recommended to uncover flaws in logic, process, and implementation; automated testing techniques help enhance coverage of smart contracts and can quickly identify items that do not follow security best practices. The following phases and associated tools were used throughout the term of the assessment:
Research into the architecture, purpose, and use of the platform.
Smart contract manual code review and walkthrough to identify any logic issue.
Thorough assessment of safety and usage of critical Solidity variables and functions in scope that could lead to arithmetic related vulnerabilities.
Manual testing by custom scripts.
Graphing out functionality and contract logic/connectivity/functions (solgraph
).
Static Analysis of security for scoped contract, and imported functions. (Slither
).
Local or public testnet deployment (Foundry
, Remix IDE
).
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
1
Medium
0
Low
1
Informational
1
Security analysis | Risk level | Remediation Date |
---|---|---|
Inconsistent Bit Shifting Leading To Incorrect Permission Handling | Critical | Solved - 09/25/2024 |
Incorrect Encoding Due To Operation Precedence | Critical | Solved - 09/25/2024 |
Incorrect Logical Operator In Tranche Validation | High | Solved - 09/26/2024 |
Missing Total Tranche Amount Validation | Low | Solved - 09/25/2024 |
Inadequate Bit Encoding In encodeRightsFromFlags Function | Informational | Acknowledged |
// Critical
In the permission management system defined in the Uint8CodecLib contract there is an inconsistency in how byte positions are handled between encoding and decoding functions.
In the _updateNthByte
function, which is responsible for updating a specific byte within a 256-bit encoding, the position is treated as a bit index. Bitwise operations use position directly without converting it to bits (i.e., without multiplying by 8).
function _updateNthByte(uint256 encoding, uint8 newValue, uint8 position) private pure returns (uint256) {
// Create a mask with the relevant bits set to 0
uint256 mask = ((uint256(type(uint8).max)) << position);
// Clear the relevant bits in the originalValue and set them to the new value
return (encoding & ~mask) | ((uint256(newValue) << position) & mask);
}
However, the _decodeNthByte
function treats n
(equivalent to position) as a byte index and multiplies it by 8 to shift by the correct number of bits during decoding.
function _decodeNthByte(uint256 data, uint8 n) private pure returns (uint8) {
return uint8((data >> (8 * n)) & 0xFF);
}
This inconsistency causes the encoding and decoding processes to operate on different bit positions. As a result, when attempting to set a permission value in a specific byte, the value is stored in an unexpected location within the provided encoding. Consequently, retrieving the permission using the decoding function returns incorrect results, leading to broken permissions system.
function test_updateProtocolPermission() public pure {
uint256 encoding = 0;
uint256 encodeConcreteerPermissions = Uint8CodecLib.updateConcreteerPermission(0, 3);
uint256 encodeHubAnyonePermissions = Uint8CodecLib.updateHubAnyonePermission(encodeConcreteerPermissions, 3);
uint256 encodeRemoteAnyonePermissions = Uint8CodecLib.updateRemoteAnyonePermission(encodeHubAnyonePermissions, 3);
uint256 encodeRemoteProtocolPermission = Uint8CodecLib.updateRemoteProtocolProxyPermission(encodeRemoteAnyonePermissions, 3);
uint8 decodedConcreteerPermision = Uint8CodecLib.getConcreteerPermission(encodeConcreteerPermissions);
assertNotEq(decodedConcreteerPermision, 3);
uint8 decodeHubAnyonePermissions = Uint8CodecLib.getHubAnyonePermission(encodeHubAnyonePermissions);
assertNotEq(decodeHubAnyonePermissions, 3);
uint8 decodeRemoteAnyonePermissions = Uint8CodecLib.getRemoteAnyonePermission(encodeRemoteAnyonePermissions);
assertNotEq(decodeRemoteAnyonePermissions, 3);
uint8 decodeRemoteProtocolPermission = Uint8CodecLib.getRemoteProtocolProxyPermission(encodeRemoteProtocolPermission);
assertNotEq(decodeRemoteProtocolPermission, 3);
}
Standardize the bit-shifting operations in both encoding and decoding functions to use consistent units.
function _updateNthByte(uint256 encoding, uint8 newValue, uint8 position) private pure returns (uint256) {
uint256 shift = uint256(position) * 8; // Convert position to bits
uint256 mask = (uint256(0xFF) << shift);
// Clear the target byte and set the new value
return (encoding & ~mask) | (uint256(newValue) << shift);
}
SOLVED: The Concrete team fixed the issue as recommended.
// Critical
The function encodeFeesAreBorrowedAndCreditInterestsAccrueOnUser
is intended to encode two uint8 values,feesAreBorrowed
and creditInterestsAccrueOnUser
, into a single uint256 by placing them in separate bytes. The feesAreBorrowed
should occupy the least significant byte, and creditInterestsAccrueOnUser
should be shifted left by 8 bits to occupy the second byte.
function encodeFeesAreBorrowedAndCreditInterestsAccrueOnUser(
uint8 feesAreBorrowed,
uint8 creditInterestsAccrueOnUser
) internal pure returns (uint256) {
return uint256(feesAreBorrowed) | (creditInterestsAccrueOnUser) << 8;
}
However the shift operation is not executed as intended. This is becauses the bitwise OR operation occurs before the shift, resulting in the creditInterestsAccrueOnUser
value being encoded incorrectly then shifted, which leads to improper encoding and decodeCreditInterestsAccrueOnUser
check always returns false.
function test_encodeFeesAreBorrowedAndCreditInterestsAccrueOnUser() public pure{
for(uint256 i; i<type(uint8).max; i++){
uint256 encoded = Uint8CodecLib.encodeFeesAreBorrowedAndCreditInterestsAccrueOnUser(1, 1);
assertNotEq(Uint8CodecLib.decodeCreditInterestsAccrueOnUser(encoded), true);
}
}
Modify the encoding function to enforce the correct order of operations by adding explicit parentheses and appropriate type casting:
return uint256(feesAreBorrowed) | (uint256(creditInterestsAccrueOnUser) << 8)
SOLVED: The Concrete team fixed the issue. The function was removed from the contract.
// High
The getTrancheAmountFractionInWad
and getTrancheFeeFractionInWad
functions from ProtectionLibV1 aims to extract a fraction value associated with a specific tranche from a packed uint256 encoding. They both uses the same condition to validate the tranche.
if ((tranche < 1) && (tranche > numberOfTranches)) revert Errors.InvalidTrancheNumber(tranche);
The condition uses the logical AND operator (&&
), which means the revert statement will only be executed if both conditions are true simultaneously. A tranche number cannot be both less than 1 and greater than numberOfTranches
at the same time. These two conditions are mutually exclusive.
As a result, invalid tranche numbers that are either less than 1 or greater than numberOfTranches
will not trigger the revert, potentially leading to incorrect calculations or out-of-bounds errors later in the function.
The correct logical operator to use in this context is the OR operator (||
):
if ((tranche < 1) || (tranche > numberOfTranches)) revert Errors.InvalidTrancheNumber(tranche);
SOLVED: The Concrete team fixed the issue as recommended.
// Low
The function encodeProtectionDataFromAbsoluteValues
and encodeProtectionData
from ProtectionLibV1 contract, currently checks individual tranche amounts and fees to ensure they do not exceed the promisedAmountInBase
.
function encodeProtectionDataFromAbsoluteValues(
uint256 promisedAmountInBase,
ProtectionDataAbsolute memory protectionData
) internal pure returns (uint256) {
if (protectionData.numberOfTranches > 3) revert Errors.NumberOfProtectionClaimsTooHigh();
if (protectionData.ltvProtectForClaimsInBP > MILLION) revert Errors.FractionExceedsUnityInBP();
if (protectionData.ltvProtectForForeclosureInBP > MILLION) revert Errors.FractionExceedsUnityInBP();
if (protectionData.openingFeeInBase > promisedAmountInBase) {
revert Errors.OpeningFeeExceedsPromisedAmount();
}
if (protectionData.cancellationFeeInBase > promisedAmountInBase) {
revert Errors.CancellationFeeExceedsPromisedAmount();
}
if (protectionData.trancheAmountInBase.length != protectionData.numberOfTranches) {
revert Errors.InvalidTrancheNumber(uint8(protectionData.trancheAmountInBase.length));
}
if (protectionData.trancheFeeInBase.length != protectionData.numberOfTranches) {
revert Errors.InvalidTrancheNumber(uint8(protectionData.trancheFeeInBase.length));
}
for (uint8 i = 0; i < protectionData.numberOfTranches; i++) {
if (protectionData.trancheAmountInBase[i] > promisedAmountInBase) {
revert Errors.TrancheAmountExceedsPromisedAmount(i + 1);
}
if (protectionData.trancheFeeInBase[i] > promisedAmountInBase) {
revert Errors.TrancheFeeExceedsPromisedAmount(i + 1);
}
function encodeProtectionData(ProtectionData memory protectionData) internal pure returns (uint256) {
if (protectionData.numberOfTranches > 3) revert Errors.NumberOfProtectionClaimsTooHigh();
if (protectionData.ltvProtectClaims > MILLION) revert Errors.FractionExceedsUnityInMillionth();
if (protectionData.ltvProtectForeclosure > MILLION) revert Errors.FractionExceedsUnityInMillionth();
if (protectionData.openingFeeRate > MILLION) revert Errors.FractionExceedsUnityInMillionth();
if (protectionData.cancellationFeeRate > MILLION) revert Errors.FractionExceedsUnityInMillionth();
if (protectionData.trancheAmountFractionInMillionth.length != protectionData.numberOfTranches) {
revert Errors.InvalidTrancheNumber(uint8(protectionData.trancheAmountFractionInMillionth.length));
}
if (protectionData.trancheFeeRateInMillionth.length != protectionData.numberOfTranches) {
revert Errors.InvalidTrancheNumber(uint8(protectionData.trancheFeeRateInMillionth.length));
}
for (uint8 i = 0; i < protectionData.numberOfTranches; i++) {
if (protectionData.trancheAmountFractionInMillionth[i] > MILLION) {
revert Errors.FractionExceedsUnityInMillionth();
}
However, it fails to verify whether the cumulative sum of all tranche amounts exceeds the promisedAmountInBase
. This oversight could allow the total amount of tranches to surpass the promised amount, leading to potential discrepancies in the contracts logic.
function test_tranchesShouldNoGreaterPromisedAmount() external {
uint256 promisedAmountInCollateral = 100 ether;
uint256[] memory trancheAmountInCollateral = new uint256[](3);
uint256[] memory trancheFeeInCollateral = new uint256[](3);
trancheAmountInCollateral[0] = 100 ether;
trancheAmountInCollateral[1] = 100 ether;
trancheAmountInCollateral[2] = 100 ether;
trancheFeeInCollateral[0] = 10 ether;
trancheFeeInCollateral[1] = 10 ether;
trancheFeeInCollateral[2] = 10 ether;
ProtectionDataAbsolute memory protectionDataAbsolute = ProtectionDataAbsolute({
endTime: uint40(block.timestamp + DURATION),
numberOfTranches: 3,
protocolRights: 0,
remoteProxyRights: 0,
remoteConcreteerRights: 0,
remotePublicRights: 0,
ltvProtectForClaimsInBP: concreteClaimBufferInBP,
ltvProtectForForeclosureInBP: concreteForeclosureBufferInBP,
openingFeeInBase: 1 ether,
cancellationFeeInBase: 1 ether,
trancheAmountInBase: trancheAmountInCollateral,
trancheFeeInBase: trancheFeeInCollateral
});
//This should revert
ProtectionLibV1.encodeProtectionDataFromAbsoluteValues(promisedAmountInCollateral, protectionDataAbsolute);
}
It is recommended to add checks to validate the cumulative sum of all tranche amounts does not exceed the promisedAmountInBase
.
SOLVED: The Concrete team resolved the issue as recommended.
// Informational
The encodeRightsFromFlags
function aims to encode four boolean flags into specific bits within a uint8 value.
function encodeRightsFromFlags(
bool protocolCanIntervene,
bool remoteProxyCanIntervene,
bool remoteConcreteerCanIntervene,
bool remotePublicCanIntervene
) internal pure returns (uint8) {
return (protocolCanIntervene ? 0 : 3) | (remoteProxyCanIntervene ? (3 << 2) : 0)
| (remoteConcreteerCanIntervene ? (3 << 4) : 0) | (remotePublicCanIntervene ? (3 << 6) : 0);
}
However, the ternary operations are applied inconsistently:
For protocolCanIntervene
, when the flag is true, it sets the bits to 0 - when false, it sets them to 3.
For the other flags, when the flag is true, it sets the bits to shifted 3 - when false, it sets them to 0.
This reversal of logic causes the bits to be incorrectly set, leading to potential misinterpretation of access rights.
Consider adjusting the function to use consistent logic across all flags.
ACKNOWLEDGED: The Concrete team acknowledged the issue. This is intention. The default rights setting ought to be that the rights for the protocol are enabled, but for the others not. Since in the EVM by default every uint256 value (such as the protocol Info encoding) is zero, it would be great to reflect the default rights encoding also in this case, e.g. where no protection exists (i.e. the protocol Info = 0). The team solved this issue by reversing the rights encoding for the protocol vs. the other parties. For the protocol, the default is that it can close and foreclose and claim and reclaim.
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