Platform: Code4rena
Start Date: 01/08/2022
Pot Size: $50,000 USDC
Total HM: 26
Participants: 133
Period: 5 days
Judge: Jack the Pug
Total Solo HM: 6
Id: 151
League: ETH
Rank: 12/133
Findings: 4
Award: $959.49
🌟 Selected for report: 1
🚀 Solo Findings: 0
165.6336 USDC - $165.63
checkSignatureValidity() verification by signature do not utilize nonces and can be tricked by using owner / builder signatures from earlier calls. Namely, while checkSignatureValidity's approvedHashes
based way can used only once as it deletes the corresponding array entry, the _signature
based logic can be reused and itself contains no nonce, just validating that the signature for the constant message is from the desired actor. This validation will go through for all the subsequent calls, which provides a way to bypass the verification by supplying previously recorded signature for the same hash used in a different operation.
Signatory replay is a low level issue that opens up a range of attack surfaces. Currently all the uses besides escrow() utilize nonces, so the impact consists of escrow() calls being repeated as there is no nonce in the data, reducing the debt up to zero.
escrow() doesn't has nonce in the _hash
, so can be rerun with the same _data
:
function escrow(bytes calldata _data, bytes calldata _signature) external virtual override whenNotPaused { // Decode params from _data ( uint256 _communityID, address _builder, address _lender, address _agent, address _project, uint256 _repayAmount, bytes memory _details ) = abi.decode( _data, (uint256, address, address, address, address, uint256, bytes) ); // Compute hash from bytes bytes32 _hash = keccak256(_data); // Local instance of variable. For saving gas. IProject _projectInstance = IProject(_project); // Revert if decoded builder is not decoded project's builder require(_builder == _projectInstance.builder(), "Community::!Builder"); // Revert if decoded _communityID's owner is not decoded _lender require( _lender == _communities[_communityID].owner, "Community::!Owner" ); // check signatures checkSignatureValidity(_lender, _hash, _signature, 0); // must be lender checkSignatureValidity(_builder, _hash, _signature, 1); // must be builder checkSignatureValidity(_agent, _hash, _signature, 2); // must be agent or escrow // Internal call to reduce debt _reduceDebt(_communityID, _project, _repayAmount, _details); emit DebtReducedByEscrow(_agent); }
checkSignatureValidity() allows two ways of verification, where approvedHashes
based way can used only once, while _signature
based approach can be reused repeatedly:
function checkSignatureValidity( address _address, bytes32 _hash, bytes memory _signature, uint256 _signatureIndex ) internal virtual { // Decode signer address _recoveredSignature = SignatureDecoder.recoverKey( _hash, _signature, _signatureIndex ); // Revert if decoded signer does not match expected address // Or if hash is not approved by the expected address. require( _recoveredSignature == _address || approvedHashes[_address][_hash], "Community::invalid signature" ); // Delete from approvedHash. So that signature cannot be reused. delete approvedHashes[_address][_hash]; }
This holds as SignatureDecoder's recoverKey() can be run with the same signature, there is no nonce, so it can be run for the same messageHash
:
function recoverKey( bytes32 messageHash, bytes memory messageSignatures, uint256 pos ) internal pure returns (address) { if (messageSignatures.length % 65 != 0) { return (address(0)); } uint8 v; bytes32 r; bytes32 s; (v, r, s) = signatureSplit(messageSignatures, pos); // If the version is correct return the signer address if (v != 27 && v != 28) { return (address(0)); } else { // solium-disable-next-line arg-overflow return ecrecover(toEthSignedMessageHash(messageHash), v, r, s); } }
To target the root source nonce has to be presented in all the signature verifications, invalidating all the subsequent uses.
This way consider ensuring that in all the instances checkSignatureValidity() is called for the hash that includes a specific nonce for the functionality. Whenever this doesn't hold the signature replay is possible with full consequences being reasonably hard to track.
#0 - horsefacts
2022-08-06T20:28:29Z
🌟 Selected for report: hyh
Also found by: hansfriese
705.4233 USDC - $705.42
Malicious builder/contractor can change the subcontractor for any task even if all the terms was agreed upon and work was started/finished, but the task wasn't set to completed yet, i.e. it's SCConfirmed
, getAlerts(_taskID)[2] == true
. This condition is not checked by inviteSC().
For example, a contractor can create a subcontractor of her own and front run valid setComplete() call with a sequence of inviteSC(task, own_subcontractor) -> setComplete()
with a signatory from the own_subcontractor
, stealing the task budget from the subcontractor who did the job. Contractor will not breach any duties with the community as the task will be done, while raiseDispute() will not work for a real subcontractor as the task record will be already changed.
Setting the severity to be high as this creates an attack vector to fully steal task budget from the subcontractor as at the moment of any valid setComplete() call the task budget belongs to subcontractor as the job completion is already verified by all the parties.
inviteSC() requires either builder or contractor to call for the change and verify nothing else:
/// @inheritdoc IProject function inviteSC(uint256[] calldata _taskList, address[] calldata _scList) external override { // Revert if sender is neither builder nor contractor. require( _msgSender() == builder || _msgSender() == contractor, "Project::!Builder||!GC" ); // Revert if taskList array length not equal to scList array length. uint256 _length = _taskList.length; require(_length == _scList.length, "Project::Lengths !match"); // Invite subcontractor for each task. for (uint256 i = 0; i < _length; i++) { _inviteSC(_taskList[i], _scList[i], false); } emit MultipleSCInvited(_taskList, _scList); }
_inviteSC() only checks non-zero address and calls inviteSubcontractor():
function _inviteSC( uint256 _taskID, address _sc, bool _emitEvent ) internal { // Revert if sc to invite is address 0 require(_sc != address(0), "Project::0 address"); // Internal call to tasks invite contractor tasks[_taskID].inviteSubcontractor(_sc); // If `_emitEvent` is true (called via changeOrder) then emit event if (_emitEvent) { emit SingleSCInvited(_taskID, _sc); } }
inviteSubcontractor() just sets the new value:
function inviteSubcontractor(Task storage _self, address _sc) internal onlyInactive(_self) { _self.subcontractor = _sc; }
Task is paid only on completion by setComplete():
// Mark task as complete. Only works when task is active. tasks[_taskID].setComplete(); // Transfer funds to subcontractor. currency.safeTransfer( tasks[_taskID].subcontractor, tasks[_taskID].cost );
This way the absence of getAlerts(_taskID)[2]
check and checkSignatureTask() call in inviteSC() provides a way for builder or contractor to steal task budget from a subcontractor.
Consider calling checkSignatureTask() when getAlerts(_taskID)[2]
is true, schematically:
// Invite subcontractor for each task. for (uint256 i = 0; i < _length; i++) { + if (getAlerts(_taskList[i])[2]) + checkSignatureTask(_data_with_scList[i], _signature, _taskList[i]); _inviteSC(_taskList[i], _scList[i], false); }
This approach is already implemented in changeOrder() where _newSC
is a part of hash that has to be signed by all the parties:
function changeOrder(bytes calldata _data, bytes calldata _signature) external override nonReentrant { // Decode params from _data ( uint256 _taskID, address _newSC, uint256 _newCost, address _project ) = abi.decode(_data, (uint256, address, uint256, address)); // If the sender is disputes contract, then do not check for signatures. if (_msgSender() != disputes) { // Check for required signatures. checkSignatureTask(_data, _signature, _taskID); }
// If new subcontractor is not zero address. if (_newSC != address(0)) { // Invite the new subcontractor for the task. _inviteSC(_taskID, _newSC, true); }
checkSignatureTask() checks all the signatures:
// When builder has not delegated rights to contractor else { // Check for B, SC and GC signatures checkSignatureValidity(builder, _hash, _signature, 0); checkSignatureValidity(contractor, _hash, _signature, 1); checkSignatureValidity(_sc, _hash, _signature, 2); }
#0 - zgorizzo69
2022-08-06T21:53:19Z
When a SC accepts an invitation the task is marked as active https://github.com/code-423n4/2022-08-rigor/blob/5ab7ea84a1516cb726421ef690af5bc41029f88f/contracts/libraries/Tasks.sol#L128 So as you noted here above the inviteSubcontractor for the same task will fail because of the modifier.
function inviteSubcontractor(Task storage _self, address _sc) internal onlyInactive(_self) {
#1 - dmitriia
2022-08-10T12:27:40Z
Yes, you are right, in general case onlyInactive
modifier guards the reset.
The issue appears to be more specific, in the case when task budget is increased, while there is no budget to cover it, i.e. totalLent - _totalAllocated < _newCost - _taskCost
, the subcontractor signs only the budget increase itself, while subcontractor ends up being unassigned from it fully:
// If tasks are already allocated with old cost. if (tasks[_taskID].alerts[1]) { // If new task cost is less than old task cost. if (_newCost < _taskCost) { // Find the difference between old - new. uint256 _withdrawDifference = _taskCost - _newCost; // Reduce this difference from total cost allocated. // As the same task is now allocated with lesser cost. totalAllocated -= _withdrawDifference; // Withdraw the difference back to builder's account. // As this additional amount may not be required by the project. autoWithdraw(_withdrawDifference); } // If new cost is more than task cost but total lent is enough to cover for it. else if (totalLent - _totalAllocated >= _newCost - _taskCost) { // Increase the difference of new cost and old cost to total allocated. totalAllocated += _newCost - _taskCost; } // If new cost is more than task cost and totalLent is not enough. else { // Un-confirm SC, mark task as inactive, mark allocated as false, mark lifecycle as None // Mark task as inactive by unapproving subcontractor. // As subcontractor can only be approved if task is allocated _unapproved = true; tasks[_taskID].unApprove(); // Mark task as not allocated. tasks[_taskID].unAllocateFunds(); // Reduce total allocation by old task cost. // As as needs to go though funding process again. totalAllocated -= _taskCost; // Add this task to _changeOrderedTask array. These tasks will be allocated first. _changeOrderedTask.push(_taskID); } }
Suppose task is 95% complete, its budget is fully spent, so changeOrder() is called per mutual agreement to add extra 0.05 * old_cost / 0.95
funds, which aren't lent yet. Dishonest contractor can call inviteSC
with own subcontractor, who will receive full old_cost / 0.95
on completion.
I.e. fully removing subcontractor from already funded and started task provides a more specific similar attack surface.
By definition unApprove
deals with the case of new task that needs to be reviewed:
/** * @dev Set a task as un accepted/approved for SC * @dev modifier onlyActive * @param _self Task the task being set as funded */ function unApprove(Task storage _self) internal { // State/ lifecycle // _self.alerts[uint256(Lifecycle.SCConfirmed)] = false; _self.state = TaskStatus.Inactive; }
But in changeOrder() all the parties already reviewed and accepted the terms:
// Decode params from _data ( uint256 _taskID, address _newSC, uint256 _newCost, address _project ) = abi.decode(_data, (uint256, address, uint256, address)); // If the sender is disputes contract, then do not check for signatures. if (_msgSender() != disputes) { // Check for required signatures. checkSignatureTask(_data, _signature, _taskID); }
// When builder has not delegated rights to contractor else { // Check for B, SC and GC signatures checkSignatureValidity(builder, _hash, _signature, 0); checkSignatureValidity(contractor, _hash, _signature, 1); checkSignatureValidity(_sc, _hash, _signature, 2); }
So, marking the task as not active and not SCConfirmed doesn't look correct in this case.
Straightforward mitigation here is to keep it active, i.e. do partial flag removal, say do unConfirm
instead of unApprove
:
function unConfirm(Task storage _self) internal { // State/ lifecycle // _self.alerts[uint256(Lifecycle.SCConfirmed)] = false; }
#2 - dmitriia
2022-08-10T22:32:23Z
A little bit more complex, but more correct (project logic aligned) mitigation is:
deActivate
instead of unConfirm
:function deActivate(Task storage _self) internal { // State/ lifecycle // _self.state = TaskStatus.Inactive; }
onlyUnconfirmed
modifier and set it to the inviteSubcontractor() and acceptInvitation():/// @dev only allow unconfirmed tasks. modifier onlyUnconfirmed(Task storage _self) { require( !_self.alerts[uint256(Lifecycle.SCConfirmed)], "Task::SCConfirmed" ); _; }
function inviteSubcontractor(Task storage _self, address _sc) internal - onlyInactive(_self) + onlyUnconfirmed(_self) { _self.subcontractor = _sc; }
function acceptInvitation(Task storage _self, address _sc) internal - onlyInactive(_self) + onlyUnconfirmed(_self) { // Prerequisites // require(_self.subcontractor == _sc, "Task::!SC"); // State/ lifecycle // _self.alerts[uint256(Lifecycle.SCConfirmed)] = true; _self.state = TaskStatus.Active; }
onlyInactive
can then be removed:
/// @dev only allow inactive tasks. Task is inactive if SC is unconfirmed. modifier onlyInactive(Task storage _self) { require(_self.state == TaskStatus.Inactive, "Task::active"); _; }
else { - // Un-confirm SC, mark task as inactive, mark allocated as false, mark lifecycle as None - // Mark task as inactive by unapproving subcontractor. - // As subcontractor can only be approved if task is allocated - _unapproved = true; - tasks[_taskID].unApprove(); + // Mark task as inactive, mark allocated as false, add to the allocation queue + // Mark task as inactive + tasks[_taskID].deActivate(); // Mark task as not allocated. tasks[_taskID].unAllocateFunds(); // Reduce total allocation by old task cost. // As as needs to go though funding process again. totalAllocated -= _taskCost; // Add this task to _changeOrderedTask array. These tasks will be allocated first. _changeOrderedTask.push(_taskID); }
Notice that the mitigation here is to make Active and SCConfirmed states independent (as a general note, it doesn't make much sense to have some fully coinciding states). Active flags whether task is in progress right now, while SCConfirmed flags whether it ever was started, being either Active (work is being done right now) or Inactive (work had started, something was done, now it's paused).
The issue basically means that this states are different and moving a task to another SC while it's SCConfirmed should be prohibited as some work was done and some payment to current SC is due
#3 - parv3213
2022-08-11T06:41:36Z
Agree to the risk, but the severity should be 2.
#4 - jack-the-pug
2022-08-27T05:35:47Z
This is a good one with a very detailed explanation, but I'm afraid it fits a Medium severity better, as funds are not directly at risk but rather a malfunctioning feature that can indirectly cause damage to certain roles.
🌟 Selected for report: Lambda
Also found by: 0x1f8b, 0x52, 0xA5DF, 0xNazgul, 0xNineDec, 0xSmartContract, 0xSolus, 0xf15ers, 0xkatana, 0xsolstars, 8olidity, Aymen0909, Bahurum, Bnke0x0, CertoraInc, Chom, CodingNameKiki, Deivitto, Dravee, ElKu, Extropy, Funen, GalloDaSballo, Guardian, IllIllI, JC, Jujic, MEP, Noah3o6, ReyAdmirado, Rohan16, Rolezn, Ruhum, Sm4rty, SooYa, Soosh, Throne6g, TomJ, Tomio, TrungOre, Waze, Yiko, _Adam, __141345__, a12jmx, ajtra, ak1, arcoun, asutorufos, ayeslick, benbaessler, berndartmueller, bin2chen, bobirichman, brgltd, bulej93, byndooa, c3phas, codexploder, cryptonue, cryptphi, defsec, delfin454000, dipp, djxploit, erictee, exd0tpy, fatherOfBlocks, gogo, hake, hansfriese, horsefacts, hyh, ignacio, indijanc, joestakey, kaden, mics, minhquanym, neumo, obront, oyc_109, p_crypt0, pfapostol, poirots, rbserver, robee, rokinot, rotcivegaf, sach1r0, saian, samruna, saneryee, scaraven, sikorico, simon135, sseefried, supernova
42.5475 USDC - $42.55
One step process offers no protection for the cases when ownership transfer is performed mistakenly or with any malicious intent.
Adding a modest complexity of an additional step and a delay is a low price to pay for having time to evaluate the change.
HomeFi sets a new admin immediately:
HomeFi's admin:
function replaceAdmin(address _newAdmin) external override onlyAdmin nonZero(_newAdmin) noChange(admin, _newAdmin) { // Replace admin admin = _newAdmin; emit AdminReplaced(_newAdmin); }
HomeFiProxy calls for the proxyAdmin ownership transfer immediately:
function changeProxyAdminOwner(address _newAdmin) external onlyOwner nonZero(_newAdmin) { // Transfer ownership to new admin. proxyAdmin.transferOwnership(_newAdmin); }
Consider utilizing two-step ownership transferring process (proposition and acceptance in separate actions) with a noticeable delay between the steps to enforce the transparency and stability of the system
Consider checking for the validity of the new fee (1-1000):
function replaceLenderFee(uint256 _newLenderFee) external override onlyAdmin { // Revert if no change in lender fee require(lenderFee != _newLenderFee, "HomeFi::!Change"); // Reset variables lenderFee = _newLenderFee; emit LenderFeeReplaced(_newLenderFee); }
// Emit event if _hash. This way this hash needs not be stored in memory. emit UpdateCommunityHash(_communityID, _hash);
Update the comment, for example: // Emit event broadcasting new _hash. This way this hash needs not be stored in memory
claimInterest() mints new _wrappedToken
, not burns it:
// Burn _interestEarned amount wrapped token to lender _wrappedToken.mint(_lender, _interestEarned);
Should be mint
as the debt record is increased
To be Revert
x2:
// Revet if sender is not builder or contractor require( signer == builder || signer == contractor, "Project::!(GC||Builder)" ); } else { // Revet if sender is not builder, contractor or task's subcontractor
To be // As it needs to go though
:
// Reduce total allocation by old task cost. // As as needs to go though funding process again. totalAllocated -= _taskCost;
unApprove() description doesn't match the logic:
/** * @dev Set a task as un accepted/approved for SC * @dev modifier onlyActive * @param _self Task the task being set as funded */ function unApprove(Task storage _self) internal { // State/ lifecycle // _self.alerts[uint256(Lifecycle.SCConfirmed)] = false; _self.state = TaskStatus.Inactive; }
Whenever _signature isn't valid there is no revert, but zero _signer
will be put to disputes structure:
function raiseDispute(bytes calldata _data, bytes calldata _signature) external override onlyProject { // Recover signer from signature address _signer = SignatureDecoder.recoverKey( keccak256(_data), _signature, 0 ); // Decode params from _data ( address _project, uint256 _taskID, uint8 _actionType, bytes memory _actionData, bytes memory _reason ) = abi.decode(_data, (address, uint256, uint8, bytes, bytes)); // Revert if _actionType is invalid require( _actionType > 0 && _actionType <= uint8(ActionType.TaskPay), "Disputes::!ActionType" ); // Store dispute details Dispute storage _dispute = disputes[disputeCount]; _dispute.status = Status.Active; _dispute.project = _project; _dispute.taskID = _taskID; _dispute.raisedBy = _signer;
changeOrder() hard codes 1
as TaskAllocated
enumeration item:
// If tasks are already allocated with old cost. if (tasks[_taskID].alerts[1]) {
Consider using the constant to improve readability and reduce operation error surface:
// If tasks are already allocated with old cost. - if (tasks[_taskID].alerts[1]) { + if (tasks[_taskID].alerts[Lifecycle.TaskAllocated]) {
#0 - parv3213
2022-08-07T07:34:16Z
raiseDispute
has onlyProject
modifier. And signatures are checked inside Project's raiseDispute
.#1 - dmitriia
2022-08-11T12:46:32Z
Yes, Disputes' raiseDispute() one is actually Gas, as the same signature is checked twice, listed it there. I.e. the mitigation here is just to remove the second signature verification, calling from Project with already verified signer as an argument instead
🌟 Selected for report: c3phas
Also found by: 0x040, 0x1f8b, 0xA5DF, 0xNazgul, 0xSmartContract, 0xSolus, 0xc0ffEE, 0xkatana, 0xsam, 8olidity, Aymen0909, Bnke0x0, CertoraInc, Chinmay, Chom, CodingNameKiki, Deivitto, Dravee, ElKu, Extropy, Fitraldys, Funen, GalloDaSballo, Guardian, IllIllI, JC, Lambda, MEP, Metatron, MiloTruck, Noah3o6, NoamYakov, PaludoX0, ReyAdmirado, Rohan16, Rolezn, Ruhum, Sm4rty, SooYa, TomJ, Tomio, Waze, _Adam, __141345__, a12jmx, ajtra, ak1, apostle0x01, asutorufos, ballx, benbaessler, bharg4v, bobirichman, brgltd, cryptonue, defsec, delfin454000, dharma09, djxploit, durianSausage, eierina, erictee, fatherOfBlocks, gerdusx, gogo, hake, hyh, ignacio, jag, kaden, kyteg, lucacez, mics, minhquanym, oyc_109, pfapostol, rbserver, ret2basic, robee, rokinot, sach1r0, saian, samruna, scaraven, sikorico, simon135, supernova, teddav, tofunmi, zeesaw
45.8909 USDC - $45.89
Disputes' raiseDispute() is called only by Project's raiseDispute() with _data
passed over:
/// @inheritdoc IProject function raiseDispute(bytes calldata _data, bytes calldata _signature) external override { // Recover the signer from the signature address signer = SignatureDecoder.recoverKey( keccak256(_data), _signature, 0 );
// Make a call to Disputes contract raiseDisputes. IDisputes(disputes).raiseDispute(_data, _signature); }
Disputes' raiseDispute() repeats SignatureDecoder.recoverKey(keccak256(_data),_signature, 0)
with the same result:
function raiseDispute(bytes calldata _data, bytes calldata _signature) external override onlyProject { // Recover signer from signature address _signer = SignatureDecoder.recoverKey( keccak256(_data), _signature, 0 );
Consider introducing the signer argument and sending the _signer
to the downstream raiseDispute():
// Make a call to Disputes contract raiseDisputes. - IDisputes(disputes).raiseDispute(_data, _signature); + IDisputes(disputes).raiseDispute(_data, _signature, _signer); }
lendToProject() does three calls instead of one:
// Local instance of variable. For saving gas. address _sender = _msgSender();
// Transfer _lenderFee to HomeFi treasury from lender account _currency.safeTransferFrom(_msgSender(), homeFi.treasury(), _lenderFee); // Transfer _amountToProject to _project from lender account _currency.safeTransferFrom(_msgSender(), _project, _amountToProject);
As _msgSender()
is already saved to memory consider using _sender
variable instead of the call
inviteSC() does two calls instead of one:
// Revert if sender is neither builder nor contractor. require( _msgSender() == builder || _msgSender() == contractor, "Project::!Builder||!GC" );
Same for acceptInviteSC():
for (uint256 i = 0; i < _length; i++) { tasks[_taskList[i]].acceptInvitation(_msgSender()); }
Consider introducing and using _sender
memory variable instead of the calls
lentAmount
is updated even if not changed, when _repayAmount <= _interest
:
if (_repayAmount > _interest) { // If repayment amount is greater than interest then // set lent = lent + interest - repayment. // And set interest = 0. uint256 _lentAndInterest = _lentAmount + _interest; // Revert if repayment amount is greater than sum of lent and interest. require(_lentAndInterest >= _repayAmount, "Community::!Liquid"); _interest = 0; _lentAmount = _lentAndInterest - _repayAmount; } else { // If repayment amount is lesser than interest, then set // interest = interest - repayment _interest -= _repayAmount; } // Update community project details _communityProject.lentAmount = _lentAmount;
Consider moving storage update to the part of logic where it happens:
if (_repayAmount > _interest) { // If repayment amount is greater than interest then // set lent = lent + interest - repayment. // And set interest = 0. uint256 _lentAndInterest = _lentAmount + _interest; // Revert if repayment amount is greater than sum of lent and interest. require(_lentAndInterest >= _repayAmount, "Community::!Liquid"); _interest = 0; _lentAmount = _lentAndInterest - _repayAmount; + _communityProject.lentAmount = _lentAmount; } else { // If repayment amount is lesser than interest, then set // interest = interest - repayment _interest -= _repayAmount; } // Update community project details - _communityProject.lentAmount = _lentAmount;
#0 - parv3213
2022-08-07T08:58:58Z
Good suggestions!