Prepared by:
HALBORN
Last Updated 05/30/2025
Date of Engagement: May 14th, 2025 - May 16th, 2025
100% of all REPORTED Findings have been addressed
All findings
9
Critical
0
High
0
Medium
2
Low
2
Informational
5
Aria Protocol
engaged Halborn
to conduct a security assessment on their smart contracts beginning on May 14th, 2025 and ending on May 16th, 2025. The security assessment was scoped to the smart contracts provided to Halborn. Commit hashes and further details can be found in the Scope section of this report.
The Aria Protocol
codebase in scope mainly consists of smart contracts implementing a staking mechanism for RWIP tokens with time-locked staking tickets and KYC verification for unstaking operations.
Halborn
was provided 3 days for the engagement and assigned 1 full-time security engineer to review the security of the smart contracts in scope. The engineer is a blockchain and smart contract security expert with advanced penetration testing and smart contract hacking skills, and deep knowledge of multiple blockchain protocols.
The purpose of the assessment is to:
Identify potential security issues within the smart contracts.
Ensure that smart contract functionality operates as intended.
In summary, Halborn
identified some improvements to reduce the likelihood and impact of risks, which were mostly addressed by the Aria Protocol team
. The main ones are the following:
Include a nonce in the signed message that increments with each use.
Replace the instance of _mint() with _safeMint() in the contract implementation to ensure recipients can properly handle ERC721 tokens.
Replace the current signature verification with OpenZeppelin's ECDSA library which includes malleability protection.
Add validation to ensure that staking amounts are greater than zero.
All addressed findings have been consolidated and incorporated into version v1.0.9, available in the following commit: https://github.com/AriaProtocol/main-contracts/tree/c0a926fa1725b1bc03cca1bd8d70a5633ddcc061.
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 this 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 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 led to arithmetic related vulnerabilities.
Local testing with custom scripts (Foundry
).
Fork testing against main networks (Foundry
).
Static analysis of security for scoped contract, and imported functions (Slither
).
Halborn
used automated testing techniques to enhance the coverage of certain areas of the smart contracts in scope. Among the tools used was Slither, a Solidity static analysis framework. After Halborn
verified the smart contracts in the repository and was able to compile them correctly into their abis and binary format, Slither was run against the 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.
The security team assessed all findings identified by the Slither software, however, findings with related to external dependencies are not included in the below results for the sake of report readability.
The findings obtained as a result of the Slither scan were reviewed, and some were not included in the report because they were determined as false positives.
EXPLOITABILITY 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
0
High
0
Medium
2
Low
2
Informational
5
Security analysis | Risk level | Remediation Date |
---|---|---|
Signature replay during unstaking | Medium | Solved - 05/19/2025 |
Unsafe token minting can lead to locked assets | Medium | Solved - 05/23/2025 |
Signature verification vulnerable to malleability | Low | Solved - 05/23/2025 |
Zero amount stake allows for unlimited ticket token minting | Low | Solved - 05/28/2025 |
Missing input validation | Informational | Acknowledged - 05/28/2025 |
Missing events | Informational | Partially Solved - 05/28/2025 |
Typo in function name | Informational | Solved - 05/28/2025 |
Use of revert strings instead of custom errors | Informational | Solved - 05/24/2025 |
Lack of named mapping | Informational | Acknowledged - 05/28/2025 |
//
The _checkKYCSignature()
function in the RWIPStaking
contract is vulnerable to signature replay attacks. When a user without an established KYC status calls the unstake()
function, they must provide a signature from an authorized KYC signer. However, the contract fails to implement any mechanism to prevent reuse of valid signatures.
The signature verification logic only checks if the signature was created by an authorized KYC signer and that it corresponds to the user's address:
if (!addressHasKYC[msg.sender]) _checkKYCSignature(msg.sender, _kycSignature);
function _checkKYCSignature(address _user, bytes memory _signature) internal view {
// Split signature
require(_signature.length == 65, "invalid signature length");
bytes32 r;
bytes32 s;
uint8 v;
assembly {
r := mload(add(_signature, 32))
s := mload(add(_signature, 64))
v := byte(0, mload(add(_signature, 96)))
}
bytes32 messageHash = keccak256(abi.encodePacked(KYC_MESSAGE, _user));
bytes32 ethSignedMessageHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash));
address recoveredAddress = ecrecover(ethSignedMessageHash, v, r, s);
require(hasRole(KYC_SIGNER_ROLE, recoveredAddress), "Not Auth");
}
The signature contains no unique identifier, like a nonce, to limit its validity period. This enables a valid signature to be reused multiple times across different transactions.
If KYC requirements change or a user's KYC status should be revoked, the existing signatures would continue to work, undermining the compliance controls of the protocol.
In the following scenario, Alice is able to re-use her signature:
function test_signatureReplay() public {
// Setup: Define amount to stake
uint256 amount = 100 ether;
uint256 stakingHoldPeriod = 7 days;
console.log("===> Setting up with stake amount:", amount / 1 ether, "RWIP");
// First stake and redeem to get stakedRWIP tokens
vm.startPrank(alice);
rwipToken.approve(address(rwipStaking), amount);
rwipStaking.stake(amount, stakingHoldPeriod);
uint256 ticketId = 0;
// Advance time past the holding period
vm.warp(block.timestamp + stakingHoldPeriod + 1);
// Redeem the ticket to get stakedRWIP tokens
rwipStaking.redeemTicket(ticketId);
console.log("Alice stakedRWIP balance after redeem:", stakedRwip.balanceOf(alice) / 1 ether);
vm.stopPrank();
// Make sure the RWIPStaking contract has RWIP tokens to return
deal(address(rwipToken), address(rwipStaking), amount * 2); // Enough for two unstakes
// Ensure alice does NOT have KYC status
vm.startPrank(signerAdmin);
rwipStaking.setKYCStatusForAddress(alice, false);
vm.stopPrank();
console.log("Alice KYC status set to FALSE - requires signature");
// Create and sign the KYC message for alice
bytes32 messageHash = keccak256(abi.encodePacked("KYC ATTESTATION FOR: ", alice));
bytes32 ethSignedMessageHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash));
// Sign the message with our signer's private key
(uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPrivateKey, ethSignedMessageHash);
bytes memory signature = abi.encodePacked(r, s, v);
// First unstake operation - use half the tokens
uint256 halfAmount = amount / 2;
console.log("===> First Unstake: Using", halfAmount / 1 ether, "stakedRWIP");
vm.startPrank(alice);
stakedRwip.approve(address(rwipStaking), halfAmount);
rwipStaking.unstake(halfAmount, signature);
// Verify first unstake was successful
console.log("First unstake successful - remaining stakedRWIP:", stakedRwip.balanceOf(alice) / 1 ether);
// VULNERABILITY: Now use the same signature for another unstake
console.log("===> Second Unstake: Using SAME signature");
stakedRwip.approve(address(rwipStaking), halfAmount);
rwipStaking.unstake(halfAmount, signature);
// Verify second unstake was also successful, proving the signature replay vulnerability
console.log("Second unstake successful - remaining stakedRWIP:", stakedRwip.balanceOf(alice) / 1 ether);
console.log("===> Signature replay vulnerability confirmed");
vm.stopPrank();
}
Implement a mechanism to prevent signature replay, for example:
Include a nonce in the signed message that increments with each use.
Add an expiration timestamp to the signature.
Track used signatures in contract storage.
SOLVED: The Aria Protocol team solved this finding in commit 4db3df6
by following the mentioned recommendation.
//
The StakingTicket
contract uses the standard ERC721 _mint()
function instead of _safeMint()
when creating new staking tickets in the mint()
function:
_mint(_to, newTokenId);
Unlike _safeMint()
, the _mint()
function does not verify whether the recipient can handle ERC721 tokens when the recipient is a contract.
This implementation allows staking tickets to be minted to contract addresses that do not implement the IERC721Receiver.onERC721Received()
function, potentially resulting in tickets being permanently locked and inaccessible. When this happens, the collateral represented by these tickets (RWIP tokens) would also be effectively lost.
Replace the instance of _mint()
with _safeMint()
in the contract implementation to ensure recipients can properly handle ERC721 tokens.
SOLVED: The Aria Protocol team solved this finding in commit e0854d6
by following the mentioned recommendation.
//
The RWIPStaking
contract uses raw ecrecover
for signature verification without protection against signature malleability. This allows multiple valid signatures to be created for the same message, potentially undermining security mechanisms that rely on signature uniqueness.
When verifying KYC signatures in the _checkKYCSignature
function, the contract uses Ethereum's native ecrecover
function:
address recoveredAddress = ecrecover(ethSignedMessageHash, v, r, s);
require(hasRole(KYC_SIGNER_ROLE, recoveredAddress), "Not Auth");
This implementation does not enforce signature canonicalization per EIP-2, which requires s
values to be in the lower half of the curve. Due to properties of elliptic curve cryptography, for any valid signature (r, s, v), another valid signature exists with parameters (r, curve_order - s, flipped v), where flipped v toggles between 27 and 28.
This vulnerability could:
Undermine any future replay protection based on signature uniqueness.
Allow the same KYC attestation to be processed with different signature representations.
Cause inconsistencies in systems that track or index KYC attestations by signature.
While not immediately exploitable for fund theft, this vulnerability represents a deviation from best practices and could interact with other protocol features that assume signature uniqueness.
In the following scenario two different signatures can recover to same signer:
function test_signatureMalleability() public {
// Setup: Define amount to stake
uint256 amount = 100 ether;
uint256 stakingHoldPeriod = 7 days;
console.log("===> Setting up test for signature malleability");
// First stake and redeem to get stakedRWIP tokens
vm.startPrank(alice);
rwipToken.approve(address(rwipStaking), amount);
rwipStaking.stake(amount, stakingHoldPeriod);
vm.warp(block.timestamp + stakingHoldPeriod + 1);
rwipStaking.redeemTicket(0);
vm.stopPrank();
// Ensure the contract has RWIP tokens to return
deal(address(rwipToken), address(rwipStaking), amount * 2);
// Ensure alice does NOT have KYC status
vm.prank(signerAdmin);
rwipStaking.setKYCStatusForAddress(alice, false);
// Create message hash for KYC attestation
bytes32 messageHash = keccak256(abi.encodePacked("KYC ATTESTATION FOR: ", alice));
bytes32 ethSignedMessageHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash));
// Get original signature
(uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPrivateKey, ethSignedMessageHash);
// Create malleated signature (with flipped s)
// secp256k1 curve order n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
bytes32 curveOrder = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141;
bytes32 malleatedS = bytes32(uint256(curveOrder) - uint256(s));
uint8 malleatedV = v == 27 ? 28 : 27; // Flip v between 27 and 28
// Pack both signatures
bytes memory originalSig = abi.encodePacked(r, s, v);
bytes memory malleatedSig = abi.encodePacked(r, malleatedS, malleatedV);
// Log the complete signatures (in hex)
console.log("Original signature:");
console.logBytes(originalSig);
console.log("Malleated signature:");
console.logBytes(malleatedSig);
console.log("The signatures above are different but validate to the same signer");
// Verify both signatures recover to the same address
address recovered1 = ecrecover(ethSignedMessageHash, v, r, s);
address recovered2 = ecrecover(ethSignedMessageHash, malleatedV, r, malleatedS);
console.log("Both signatures recover to:", recovered1);
assert(recovered1 == recovered2);
assert(keccak256(originalSig) != keccak256(malleatedSig)); // Confirm signatures are different
// Use both signatures to unstake in two separate transactions
uint256 unstakeAmount = amount / 2;
// First unstake with original signature
vm.startPrank(alice);
stakedRwip.approve(address(rwipStaking), unstakeAmount);
rwipStaking.unstake(unstakeAmount, originalSig);
// Second unstake with malleated signature
stakedRwip.approve(address(rwipStaking), unstakeAmount);
rwipStaking.unstake(unstakeAmount, malleatedSig);
vm.stopPrank();
}
Replace the current signature verification with OpenZeppelin's ECDSA library, which includes malleability protection:
address recoveredAddress = ECDSA.recover(ethSignedMessageHash, _signature);
SOLVED: The Aria Protocol team solved this finding in commit 74ca8dd
by following the mentioned recommendation.
//
The stake()
function in the RWIPStaking
contract does not validate that the staking amount is greater than zero, allowing users to mint unlimited staking tickets without actually staking any meaningful collateral.
The stake()
function in the RWIPStaking
contract lacks validation to ensure that _amount
is greater than zero:
function stake(uint256 _amount, uint256 _stakingHoldPeriod) external {
if (_stakingHoldPeriod < minStakingHoldPeriod) revert StakingHoldPeriodTooLow();
rwipToken.safeTransferFrom(msg.sender, address(stakingTicket), _amount);
uint256 stakingTicketId = stakingTicket.mint(msg.sender, _amount, _stakingHoldPeriod);
emit RWIPStaked(msg.sender, stakingTicketId, _amount, _stakingHoldPeriod);
}
This allows users to call stake(0, validHoldPeriod)
repeatedly, which:
Transfers 0 RWIP tokens (which succeeds, as ERC20 allows zero-amount transfers).
Mints a new staking ticket with 0 collateral.
Results in a valid ERC721 token being minted to the user.
While these tickets would each redeem for 0 stRWIP tokens, they remain valid NFTs that could potentially be leveraged for unintended purposes, potentially manipulating systems that use ticket token quantities for calculations.
In the following scenario, the attacker can mint 100
NFT tokens without having to transfer any ERC20 tokens to the contract:
function test_unlimitedMintWithZeroAmount() public {
vm.startPrank(attacker);
for (uint256 i = 0; i < 100; i++) rwipStaking.stake(0, 1 days);
vm.stopPrank();
assertEq(stakingTicket.balanceOf(attacker), 100);
}
Add validation to ensure that staking amounts are greater than zero.
SOLVED: The Aria Protocol team solved this finding in commit 612586a
by following the mentioned recommendation.
//
Throughout the codebase, there are several instances where input values are assigned without proper validation. For example, ensuring that an input address is not the zero address.
Failing to validate inputs before assigning them to state variables can lead to unexpected system behavior or even complete failure.
Instances of this issue include:
In StakingTicket.initialize()
, _rwipToken
and _stakingContract
are not checked against the zero address before assignment.
In StakingTicket.setRWIPToken()
, _rwipToken
is not validated to ensure it's not the zero address before assignment.
In StakingTicket.setStakingContract()
, _stakingContract
is not validated to ensure it's not the zero address before assignment.
In RWIPStaking.initialize()
, _rwipToken
, _stakedRWIPToken
, _stakingTicket
, and _kycSignerAdmin
are not checked against the zero address before assignment, and the _minStakingHoldPeriod
is not verified to fall within a valid threshold.
In RWIPStaking.unstake()
, there's no validation to ensure that _amount
is greater than zero. This allows users to unstake 0 tokens, which would waste gas and emit misleading events with zero values.
In RWIPStaking.setRWIPToken()
, _rwipToken
is not checked against the zero address before assignment.
In RWIPStaking.setMinStakingHoldPeriod()
, _minStakingHoldPeriod
is not verified to fall within a valid threshold.
In RWIPStaking.setStakedRWIPToken()
, _stakedRWIPToken
is not checked against the zero address before assignment.
In RWIPStaking.setStakingTicket()
, _stakingTicket
is not checked against the zero address before assignment.
Add proper validation to ensure that the input values are within expected ranges and that addresses are not the zero address. This will help prevent unexpected behavior and improve the overall robustness of the code.
ACKNOWLEDGED: The Aria Protocol team made a business decision to acknowledge this finding and not alter the contracts.
//
Throughout the contracts in scope, there are several instances where administrative functions change contract state by modifying core state variables without them being reflected in event emissions. The absence of events may hamper effective state tracking in off-chain monitoring systems.
Instances of this issue can be found in:
StakingTicket.setRWIPToken()
StakingTicket.setStakingContract()
RWIPStaking.withdraw()
RWIPStaking.setKYCStatusForAddress()
RWIPStaking.setMinStakingHoldPeriod()
RWIPStaking.setRWIPToken()
RWIPStaking.setStakedRWIPToken()
RWIPStaking.setStakingTicket()
Emit events for all state changes that occur as a result of administrative functions to facilitate off-chain monitoring of the system.
PARTIALLY SOLVED: The Aria Protocol team partially solved this finding in commit bfe98bdbadcbfb33b6e4affcbe9b191bb9f9a382
by following the mentioned recommendation and adding events to some of the aforementioned functions.
//
In the StakedRWIP
contract, there is a typo, where the function initialize()
is misspelled as intialize()
.
This typo may affect the functionality of the code and initialization of the contract, depending on the framework used for deployment.
It is recommended to fix the typo to improve the readability of the codebase and facilitate proper initialization.
SOLVED: The Aria Protocol team solved this finding in commit 0c53aaf
by following the mentioned recommendation.
//
Throughout the files in scope, there are several instances where revert strings are used over custom errors.
In Solidity development, replacing hard-coded revert message strings with the Error()
syntax is an optimization strategy that can significantly reduce gas costs. Hard-coded strings, stored on the blockchain, increase the size and cost of deploying and executing contracts.
The Error()
syntax allows for the definition of reusable, parameterized custom errors, leading to a more efficient use of storage and reduced gas consumption. This approach not only optimizes gas usage during deployment and interaction with the contract but also enhances code maintainability and readability by providing clearer, context-specific error information.
Consider replacing all revert strings with custom errors. For example:
error ConditionNotMet();
if (!condition) revert ConditionNotMet();
or starting from Solidity 0.8.27
:
require(condition, ConditionNotMet());
SOLVED: The Aria Protocol team solved this finding in commit 4db3df6
by following the mentioned recommendation.
//
The addressHasKYC
mapping in unnamed despite using a Solidity version that supports named mappings.
Named mappings improve code readability and self-documentation by explicitly stating their purpose.
Consider refactoring the mapping to use named arguments, which will enhance code readability and make the purpose of the mapping more explicit.
ACKNOWLEDGED: The Aria Protocol team made a business decision to acknowledge this finding and not alter the contracts.
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
Staking Contracts
* Use Google Chrome for best results
** Check "Background Graphics" in the print settings if needed