Lens Protocol V2 - juancito's results

An open technology stack, builders can create social front-ends or integrate Lens social capabilities.

General Information

Platform: Code4rena

Start Date: 17/07/2023

Pot Size: $85,500 USDC

Total HM: 11

Participants: 26

Period: 14 days

Judge: Picodes

Total Solo HM: 1

Id: 263

League: ETH

Lens Protocol

Findings Distribution

Researcher Performance

Rank: 2/26

Findings: 6

Award: $12,925.99

QA:
grade-a

🌟 Selected for report: 2

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: MiloTruck

Also found by: juancito

Labels

bug
2 (Med Risk)
disagree with severity
satisfactory
duplicate-144

Awards

2660.5978 USDC - $2,660.60

External Links

Lines of code

https://github.com/code-423n4/2023-07-lens/blob/main/contracts/LensHub.sol#L368-L373 https://github.com/code-423n4/2023-07-lens/blob/main/contracts/FollowNFT.sol#L131-L138 https://github.com/code-423n4/2023-07-lens/blob/main/contracts/misc/LensV2Migration.sol#L33 https://github.com/code-423n4/2023-07-lens/blob/main/contracts/misc/LensV2Migration.sol#L37-L41 https://github.com/code-423n4/2023-07-lens/blob/main/contracts/misc/LensV2Migration.sol#L45 https://github.com/code-423n4/2023-07-lens/blob/main/contracts/base/LensProfiles.sol#L93-L98 https://github.com/code-423n4/2023-07-lens/blob/main/contracts/base/LensProfiles.sol#L165-L169 https://github.com/code-423n4/2023-07-lens/blob/main/contracts/FollowNFT.sol#L156 https://github.com/code-423n4/2023-07-lens/blob/main/contracts/FollowNFT.sol#L164 https://github.com/code-423n4/2023-07-lens/blob/main/contracts/FollowNFT.sol#L188 https://github.com/code-423n4/2023-07-lens/blob/main/contracts/FollowNFT.sol#L255 https://github.com/code-423n4/2023-07-lens/blob/main/contracts/FollowNFT.sol#L436-L440

Vulnerability details

Summary

Important user functions on Lens V2 are protected by pause modifiers like whenNotPaused, or whenPublishingEnabled.

This includes functions to follow, unfollow profiles, set follow modules, or create profiles among others.

Impact

Several important functions that modify contract storage by users, or perform actions on tokens are not protected by any pause modifiers. This allows users to interact with the contracts, although they should not be supposed to.

The most important ones being:

  • Unfollow actions can be performed via FollowNFT::removeFollower() despite LensHub::unfollow is protected with whenNotPaused
  • Migrations can be performed on LensHub via:
    • LensV2Migration::batchMigrateProfiles()
    • LensV2Migration::batchMigrateFollows()
    • LensV2Migration::batchMigrateFollowModules()

In addition, some other FollowNFT functions could be considered for pause modifiers, as LensHub tokens have similar protections on the inherited LensProfiles contract:

  • FollowNFT::wrap()
  • FollowNFT::unwrap()
  • FollowNFT::burn()
  • FollowNFT::_beforeTokenTransfer()

Proof of Concept

Unfollow actions are prevented on paused contracts for LensHub::unfollow(), but can still be executed via FollowNFT::removeFollower():

    function unfollow(uint256 unfollowerProfileId, uint256[] calldata idsOfProfilesToUnfollow)
        external
        override
        whenNotPaused
        onlyProfileOwnerOrDelegatedExecutor(msg.sender, unfollowerProfileId)
    {

LensHub.sol#L368-L373

    function removeFollower(uint256 followTokenId) external override {
        address followTokenOwner = ownerOf(followTokenId);
        if (followTokenOwner == msg.sender || isApprovedForAll(followTokenOwner, msg.sender)) {
            _unfollowIfHasFollower(followTokenId);
        } else {
            revert DoesNotHavePermissions();
        }
    }

FollowNFT.sol#L131-L138

LensV2Migration is inherited by LensHub and has the following functions missing any pause modifiers:

    function batchMigrateProfiles(uint256[] calldata profileIds) external {

    function batchMigrateFollows(
        uint256[] calldata followerProfileIds,
        uint256[] calldata idsOfProfileFollowed,
        uint256[] calldata followTokenIds
    ) external {

    function batchMigrateFollowModules(uint256[] calldata profileIds) external {

LensProfiles is inherited by LensHub and protects certain functions regarding the token like these ones:

    function burn(uint256 tokenId)
        public
        override(LensBaseERC721, IERC721Burnable)
@>      whenNotPaused
        onlyProfileOwner(msg.sender, tokenId)
    {

    function _beforeTokenTransfer(
        address from,
        address to,
        uint256 tokenId
@>  ) internal override whenNotPaused {

On the other hand, FollowNFT has no pause protection on functions related to its token. Such as:

    function wrap(uint256 followTokenId, address wrappedTokenReceiver) external override {

    function wrap(uint256 followTokenId) external override {

    function unwrap(uint256 followTokenId) external override {

    function burn(uint256 followTokenId) public override {

    function _beforeTokenTransfer(
        address from,
        address to,
        uint256 followTokenId
    ) internal override {

Tools Used

Manual Review

Add the corresponding pause modifiers to the functions mentioned on the Impact section.

Assessed type

Other

#0 - c4-pre-sort

2023-08-04T12:41:36Z

141345 marked the issue as primary issue

#1 - vicnaum

2023-08-07T15:55:03Z

We confirm the issue, although this might be rather considered as Low instead of Medium

#2 - c4-sponsor

2023-08-07T15:55:08Z

vicnaum marked the issue as disagree with severity

#3 - c4-judge

2023-08-28T13:42:47Z

Picodes marked the issue as satisfactory

#4 - Picodes

2023-08-28T14:10:13Z

Downgrading to Low as assets nor availability of the protocol is at risk

#5 - c4-judge

2023-08-28T14:10:22Z

Picodes changed the severity to QA (Quality Assurance)

#6 - 0xJuancito

2023-08-29T03:42:13Z

Hi @Picodes. I'd like to ask if you could take a second look at this issue as a Medium risk finding, considering the Docs:

2 — Med: Assets not at direct risk, but the function of the protocol or its availability could be impacted

In this case the "function of the protocol" is impacted, and availability can also be considered as well. Not for being unavailable, but because of functions being available in moments that they shouldn't be, allowing important actions for a social network that users should not be able to perform, out of the control of the protocol.

When the protocol is paused, it should not allow unfollow actions (via removeFollower()) or new follows (via batchMigrateProfiles() for example, among all the other mentioned functions on the Impact section.

In DeFi protocols, missing pause modifiers having been evaluated as Medium like here, and here. In the case of Lens Protocol, the functions mentioned on the Impact section should be considered of ultimate importance to have under control as a social network.

#7 - MiloTruck

2023-08-30T02:10:44Z

Hi @Picodes, just wanted to mention that #144 is a duplicate of this issue in case it is upgraded. Thanks!

#8 - donosonaumczuk

2023-08-30T16:15:42Z

Yeah, I think it is a fair argument and can be upgraded to Medium.

#9 - Picodes

2023-08-31T16:29:12Z

My view on this is that it ultimately depends on the sponsor's intent. In this case, it seems clear by the above comment and what you highlighted that the intent was to be able to totally pause follows and unfollows, so you're right and I'll upgrade this to Med as a functionality is broken

#10 - c4-judge

2023-08-31T16:29:34Z

This previously downgraded issue has been upgraded by Picodes

#11 - c4-judge

2023-08-31T16:33:04Z

Picodes marked issue #144 as primary and marked this issue as a duplicate of 144

Findings Information

🌟 Selected for report: MiloTruck

Also found by: juancito, maanas

Labels

bug
2 (Med Risk)
satisfactory
duplicate-143

Awards

1596.3587 USDC - $1,596.36

External Links

Lines of code

https://github.com/code-423n4/2023-07-lens/blob/main/contracts/namespaces/LensHandles.sol#L87-L99 https://github.com/code-423n4/2023-07-lens/blob/main/contracts/namespaces/LensHandles.sol#L194-L200

Vulnerability details

Summary

There is not on-chain check that prevents users from minting handles on v2 that already exist on v1. This would prevent OG users of v1 from migrating their handles.

In order to stay true to the spirit of a decentralized social network, this check should be on-chain, and not depend on any possible off-chain list that the whitelisted profile creators, or the owner may have, as it could incomplete, with errors, or may not exist (as it is not mentioned).

Example 1: In the case a whitelisted profile creator does not keep an up to date list of the v1 profiles that should not be able to be minted, an adversary may choose a valuable Lens Handle name that hasn't been migrated.

Example 2: The contract owner can unknowingly provide a Lens Handle of an OG v1 user to another one.

This can't be undone without any upgrade.

Impact

Lens Handles represent part of the core user's identity on Lens Protocol v1. Users that own a Lens Handle on v1 should be expected to to migrate their handles to v2.

If other user mints their handle before they migrate it to v2, they will lose their right to own their corresponding handle on the new version of Lens.

The impact is permanent and can't be undone without any upgrade.

Proof of Concept

LensHandles::mintHandle() does not make any on-chain check to see if the v1 handle exists. If this function is called before migrateHandle(), the OG v1 user won't be able to migrate their handle:

    function mintHandle(address to, string calldata localName)
        external
        onlyOwnerOrWhitelistedProfileCreator
        returns (uint256)
    {
        _validateLocalName(localName);
        return _mintHandle(to, localName);
    }

    function migrateHandle(address to, string calldata localName) external onlyHub returns (uint256) {
        _validateLocalNameMigration(localName);
        return _mintHandle(to, localName);
    }

    function _mintHandle(address to, string calldata localName) internal returns (uint256) {
        uint256 tokenId = getTokenId(localName);
        _mint(to, tokenId);
        _localNames[tokenId] = localName;
        emit HandlesEvents.HandleMinted(localName, NAMESPACE, tokenId, to, block.timestamp);
        return tokenId;
    }

Tools Used

Manual Review

Check for the existence of the corresponding Lens Handle on v1 before calling to mint it with the new mintHandle() function.

Assessed type

Invalid Validation

#0 - c4-pre-sort

2023-08-04T12:33:09Z

141345 marked the issue as duplicate of #143

#1 - c4-judge

2023-08-28T15:57:21Z

Picodes marked the issue as satisfactory

Findings Information

🌟 Selected for report: MiloTruck

Also found by: juancito

Labels

bug
2 (Med Risk)
disagree with severity
satisfactory
duplicate-141

Awards

2660.5978 USDC - $2,660.60

External Links

Lines of code

https://github.com/code-423n4/2023-07-lens/blob/main/contracts/libraries/constants/Typehash.sol#L23 https://github.com/code-423n4/2023-07-lens/blob/main/contracts/libraries/constants/Typehash.sol#L15 https://github.com/code-423n4/2023-07-lens/blob/main/contracts/libraries/constants/Typehash.sol#L25 https://github.com/code-423n4/2023-07-lens/blob/main/contracts/libraries/MetaTxLib.sol#L147-L148 https://github.com/code-423n4/2023-07-lens/blob/main/contracts/libraries/MetaTxLib.sol#L245-L246 https://github.com/code-423n4/2023-07-lens/blob/main/contracts/libraries/MetaTxLib.sol#L275-L276 https://github.com/code-423n4/2023-07-lens/blob/main/contracts/libraries/constants/Types.sol#L181-L182 https://github.com/code-423n4/2023-07-lens/blob/main/contracts/libraries/constants/Types.sol#L210-L211 https://github.com/code-423n4/2023-07-lens/blob/main/contracts/libraries/constants/Types.sol#L239-L240

Vulnerability details

Impact

Signed transactions implementing EIP-712 signatures correctly will revert as their on-chain counterpart will be different.

Affected functions:

  • LensHub::postWithSig()
  • LensHub::commentWithSig()
  • LensHub::quoteWithSig()

Proof of Concept

The functions postWithSig(), commentWithSig(), and quoteWithSig() from LensHub all use EIP-712 to validate the owner/executor signature.

The contract implements incorrectly the Typehash for the EIP-712 signature, which will not be able to validate correct signatures, thus reverting those transactions.

The error is on the Typehashes assuming address collectModule, and bytes collectModuleInitData, instead of the corresponding address[] actionModules, and bytes[] actionModulesInitDatas, which have different types.

Note the use of address collectModule, and bytes collectModuleInitData on the Typehashes:

bytes32 constant POST = keccak256('Post(uint256 profileId,string contentURI,address collectModule,bytes collectModuleInitData,address referenceModule,bytes referenceModuleInitData,uint256 nonce,uint256 deadline)');

bytes32 constant COMMENT = keccak256('Comment(uint256 profileId,string contentURI,uint256 pointedProfileId,uint256 pointedPubId,uint256[] referrerProfileIds,uint256[] referrerPubIds,bytes referenceModuleData,address collectModule,bytes collectModuleInitData,address referenceModule,bytes referenceModuleInitData,uint256 nonce,uint256 deadline)');

bytes32 constant QUOTE = keccak256('Quote(uint256 profileId,string contentURI,uint256 pointedProfileId,uint256 pointedPubId,uint256[] referrerProfileIds,uint256[] referrerPubIds,bytes referenceModuleData,address collectModule,bytes collectModuleInitData,address referenceModule,bytes referenceModuleInitData,uint256 nonce,uint256 deadline)');

These are the functions where the corresponding digests are calculated:

    function validatePostSignature(Types.EIP712Signature calldata signature, Types.PostParams calldata postParams) // @audit check
        external
    {
        _validateRecoveredAddress(
            _calculateDigest(
                keccak256(
                    abi.encode(
                        Typehash.POST,
                        postParams.profileId,
                        keccak256(bytes(postParams.contentURI)),
@>                      postParams.actionModules,                                       // @audit address[] actionModules
@>                      _hashActionModulesInitDatas(postParams.actionModulesInitDatas), // @audit bytes[] actionModulesInitDatas
                        postParams.referenceModule,
                        keccak256(postParams.referenceModuleInitData),
                        _getAndIncrementNonce(signature.signer),
                        signature.deadline
                    )
                )
            ),
            signature
        );
    }

    function validateCommentSignature(
        Types.EIP712Signature calldata signature,
        Types.CommentParams calldata commentParams
    ) external {
        bytes32 contentURIHash = keccak256(bytes(commentParams.contentURI));
        bytes32 referenceModuleDataHash = keccak256(commentParams.referenceModuleData);
        bytes32 actionModulesInitDataHash = _hashActionModulesInitDatas(commentParams.actionModulesInitDatas);
        bytes32 referenceModuleInitDataHash = keccak256(commentParams.referenceModuleInitData);
        uint256 nonce = _getAndIncrementNonce(signature.signer);
        uint256 deadline = signature.deadline;
        bytes memory encodedAbi = _abiEncode(
            ReferenceParamsForAbiEncode(
                Typehash.COMMENT,
                commentParams.profileId,
                contentURIHash,
                commentParams.pointedProfileId,
                commentParams.pointedPubId,
                commentParams.referrerProfileIds,
                commentParams.referrerPubIds,
                referenceModuleDataHash,
@>              commentParams.actionModules,   // @audit address[] actionModules
@>              actionModulesInitDataHash,     // @audit bytes[] actionModulesInitDatas
                commentParams.referenceModule,
                referenceModuleInitDataHash,
                nonce,
                deadline
            )
        );
        _validateRecoveredAddress(_calculateDigest(keccak256(encodedAbi)), signature);
    }

    function validateQuoteSignature(Types.EIP712Signature calldata signature, Types.QuoteParams calldata quoteParams)
        external
    {
        bytes32 contentURIHash = keccak256(bytes(quoteParams.contentURI));
        bytes32 referenceModuleDataHash = keccak256(quoteParams.referenceModuleData);
        bytes32 actionModulesInitDataHash = _hashActionModulesInitDatas(quoteParams.actionModulesInitDatas);
        bytes32 referenceModuleInitDataHash = keccak256(quoteParams.referenceModuleInitData);
        uint256 nonce = _getAndIncrementNonce(signature.signer);
        uint256 deadline = signature.deadline;
        bytes memory encodedAbi = _abiEncode(
            ReferenceParamsForAbiEncode(
                Typehash.QUOTE,
                quoteParams.profileId,
                contentURIHash,
                quoteParams.pointedProfileId,
                quoteParams.pointedPubId,
                quoteParams.referrerProfileIds,
                quoteParams.referrerPubIds,
                referenceModuleDataHash,
@>              quoteParams.actionModules,   // @audit address[] actionModules
@>              actionModulesInitDataHash,   // @audit bytes[] actionModulesInitDatas
                quoteParams.referenceModule,
                referenceModuleInitDataHash,
                nonce,
                deadline
            )
        );
        _validateRecoveredAddress(_calculateDigest(keccak256(encodedAbi)), signature);
    }

These are the corresponding type declarations:

    struct PostParams {
        uint256 profileId;
        string contentURI;
@>      address[] actionModules;
@>      bytes[] actionModulesInitDatas;
        address referenceModule;
        bytes referenceModuleInitData;
    }

    struct CommentParams {
        uint256 profileId;
        string contentURI;
        uint256 pointedProfileId;
        uint256 pointedPubId;
        uint256[] referrerProfileIds;
        uint256[] referrerPubIds;
        bytes referenceModuleData;
@>      address[] actionModules;
@>      bytes[] actionModulesInitDatas;
        address referenceModule;
        bytes referenceModuleInitData;
    }

    struct QuoteParams {
        uint256 profileId;
        string contentURI;
        uint256 pointedProfileId;
        uint256 pointedPubId;
        uint256[] referrerProfileIds;
        uint256[] referrerPubIds;
        bytes referenceModuleData;
@>      address[] actionModules;
@>      bytes[] actionModulesInitDatas;
        address referenceModule;
        bytes referenceModuleInitData;
    }

Tools Used

Manual Review

Fix the Typehashes for COMMENT, POST, and QUOTE:

  • address collectModule -> address[] actionModules
  • bytes collectModuleInitData -> bytes[] actionModulesInitDatas

Assessed type

Other

#0 - donosonaumczuk

2023-08-08T15:13:25Z

This is a duplicate or subset from #141 - We proceed with the same resolution.

#1 - c4-sponsor

2023-08-08T15:13:30Z

donosonaumczuk marked the issue as disagree with severity

#2 - c4-judge

2023-08-28T13:41:23Z

Picodes marked the issue as duplicate of #141

#3 - c4-judge

2023-08-28T21:00:19Z

Picodes marked the issue as satisfactory

Findings Information

🌟 Selected for report: juancito

Also found by: Emmanuel, evmboi32

Labels

bug
2 (Med Risk)
primary issue
satisfactory
selected for report
sponsor confirmed
M-09

Awards

2075.2663 USDC - $2,075.27

External Links

Lines of code

https://github.com/code-423n4/2023-07-lens/blob/main/contracts/libraries/FollowLib.sol#L35-L37 https://github.com/code-423n4/2023-07-lens/blob/main/contracts/FollowNFT.sol#L480-L520

Vulnerability details

Impact

Users are not supposed to be able to self-follow on Lens v2, but they are able to bypass the restriction. This can also affect modules or newer functionalities that count on this behaviour.

Migration is an Area of specific concern for the devs, and this can easily be prevented with a simple check.

This can't be undone without any upgrade.

Proof of Concept

FollowLib::follow() has a specific restriction to revert when a user tries to self-follow on Lens v2:

    if (followerProfileId == idsOfProfilesToFollow[i]) {
        revert Errors.SelfFollow();
    }

FollowLib.sol#L35-L37

However, users that own a follow NFT from V1 can execute FollowNFT::tryMigrate() to self-follow on V2, as there is no restriction to prevent it. A test proving it can be found on the next section.

FollowNFT.sol#L480-L520

Coded POC

Add this test to test/migrations/Migrations.t.sol and run TESTING_FORK=mainnet POLYGON_RPC_URL="https://polygon.llamarpc.com" forge test --mt "testSelfFollow".

Note: In case of a memory allocation error during the Forge test, please comment these lines. They are not used for the current test.

    function testSelfFollow() public onlyFork {
        uint256 selfFollowProfileId = 3659; // juancito.lens
        uint256 selfFollowTokenId = 42;     // juancito.lens follows juancito.lens on V1

        FollowNFT nft = FollowNFT(hub.getProfile(selfFollowProfileId).followNFT);
        address user = nft.ownerOf(selfFollowTokenId); // Owner of juancito.lens

        // 1. Migrate the self-follow
        uint256[] memory selfFollowProfileIdArray = new uint256[](1);
        uint256[] memory selfFollowTokenIdArray = new uint256[](1);

        selfFollowProfileIdArray[0] = selfFollowProfileId; // 3659
        selfFollowTokenIdArray[0] = selfFollowTokenId;     // 42

        hub.batchMigrateFollows(selfFollowProfileIdArray, selfFollowProfileIdArray, selfFollowTokenIdArray);

        // 2. The user is self-following on V2
        assertTrue(nft.isFollowing(selfFollowProfileId));
    }

Tools Used

Manual Review

Add the following validation to FollowNFT::tryMigrate():

+    if (followerProfileId == _followedProfileId) {
+        return 0;
+    }

Assessed type

Invalid Validation

#0 - c4-pre-sort

2023-08-04T10:56:59Z

141345 marked the issue as primary issue

#1 - vicnaum

2023-08-07T16:05:35Z

This seems like a sub-case of this: https://github.com/code-423n4/2023-07-lens-findings/issues/112 (But the mitigation is different for this case)

#2 - c4-sponsor

2023-08-08T17:26:27Z

vicnaum marked the issue as sponsor confirmed

#3 - Picodes

2023-08-28T16:08:44Z

The impact is the same but the issue is it seems different, as the mitigation suggested by #112 wouldn't prevent this from happening

#4 - c4-judge

2023-08-28T16:08:55Z

Picodes marked the issue as satisfactory

#5 - c4-judge

2023-08-28T16:10:34Z

Picodes marked the issue as selected for report

Findings Information

🌟 Selected for report: juancito

Also found by: Emmanuel

Labels

bug
2 (Med Risk)
primary issue
satisfactory
selected for report
sponsor confirmed
M-10

Awards

3458.7772 USDC - $3,458.78

External Links

Lines of code

https://github.com/code-423n4/2023-07-lens/blob/main/contracts/FollowNFT.sol#L35-L37 https://github.com/code-423n4/2023-07-lens/blob/main/contracts/FollowNFT.sol#L216-L218 https://github.com/code-423n4/2023-07-lens/blob/main/contracts/FollowNFT.sol#L386-L391 https://github.com/code-423n4/2023-07-lens/blob/main/contracts/FollowNFT.sol#L514 https://github.com/code-423n4/2023-07-lens/blob/main/contracts/FollowNFT.sol#L501-L504

Vulnerability details

Summary

Follows in Lens v2 can be checked via isFollowing(), which returns the internal storage variable _followTokenIdByFollowerProfileId[followerProfileId] and can't be manipulated by an unauthorized party via any of the follow/unfollow functions, as they have proper access control checks.

This is also prevented on token transfers in Lens v2. If user A wraps their follow token and transfer it to user B, it doesn't mean that user B is following user A.

But using the tryMigrate() it is possible to "force" someone to follow another user without their consent.

Impact

Users can make any other user follow them.

For a social network this could even be considered a high severity issue, as follow actions are a core component of them, and migrations were marked as an Area of specific concern by the devs.

Unauthorized follow actions not only harm the user, by performing a crutial action without their consent, but may also affect other users.

It could be used for example to make prestigious profiles follow scam accounts, unwillingly ligitimating them.

Extra note: An adversary can execute this attack at any time. They can prevent having their follow NFT being migrated by frontrunning the migrate tx, and moving their token to another wallet. The migration will run silently with no error, and the attack can be performed later.

Proof of Concept

Follows can be checked by the isFollowing() function, which is dependant on the _followTokenIdByFollowerProfileId[followerProfileId] mapping. _followTokenIdByFollowerProfileId is unset for Lens v1:

    // Introduced in v2
    mapping(uint256 => uint256) internal _followTokenIdByFollowerProfileId;

    function isFollowing(uint256 followerProfileId) external view override returns (bool) {
        return _followTokenIdByFollowerProfileId[followerProfileId] != 0;
    }

The expected way to update _followTokenIdByFollowerProfileId is via the _baseFollow() internal function, which can only be called via other external functions protected with access control, only authorizing the profile owner who will follow or a delegate:

    function _baseFollow(
        uint256 followerProfileId,
        uint256 followTokenId,
        bool isOriginalFollow
    ) internal {
        _followTokenIdByFollowerProfileId[followerProfileId] = followTokenId;

FollowNFT.sol#L386-L391

However the tryMigrate() function also updates _followDataByFollowTokenId to help with migrations from V1, and can be called by anyone (via the hub):

    function tryMigrate(
        uint256 followerProfileId,
        address followerProfileOwner,
        uint256 idOfProfileFollowed,
        uint256 followTokenId
    ) external onlyHub returns (uint48) {
        // ...

        _followDataByFollowTokenId[followTokenId].followerProfileId = uint160(followerProfileId);

FollowNFT.sol#L514

The only caveat is that the "ProfileNFT and FollowNFT should be in the same account":

    address followTokenOwner = ownerOf(followTokenId);

    // ProfileNFT and FollowNFT should be in the same account
    if (followerProfileOwner != followTokenOwner) {
        return 0; // Not holding both Profile & Follow NFTs together
    }

FollowNFT.sol#L501-L504

But this can bypassed by simply transfering a V1 follow NFT from anyone to the victim, who will end up following the adversary unwillingly after the execution of tryMigrate().

Also, as noted on the Impact section, an adversary could prevent that someone else tries to migrate their profile, by frontrunning the tx and moving their token to some other address. It will "fail" silently because of the return 0; // Not holding both Profile & Follow NFTs together on the previous code snippet.

Coded POC

Add this test to test/migrations/Migrations.t.sol and run TESTING_FORK=mainnet POLYGON_RPC_URL="https://polygon.llamarpc.com" forge test --mt "testFakeFollowMigration".

Note: In case of a memory allocation error during the Forge test, please comment these lines. They are not used for the current test.

    function testFakeFollowMigration() public onlyFork {
        // lensprotocol.lens will be the victim
        // juancito.lens will be the adversary
        // The adversary will make lensprotocol.lens follow them without their consent

        uint256 victimProfileId = 1;       // lensprotocol.lens
        uint256 adversaryProfileId = 3659; // juancito.lens

        uint256 followTokenId = 42;        // juancito.lens follows juancito.lens

        FollowNFT nft = FollowNFT(hub.getProfile(adversaryProfileId).followNFT);
        address adversary = nft.ownerOf(followTokenId); // Owner of juancito.lens

        // 1. Transfer the Lens v1 follow token to the victim
        // This does not automatically imply that they are following the adversary yet
        vm.startPrank(address(adversary));
        nft.transferFrom(address(adversary), hub.ownerOf(victimProfileId), followTokenId);
        assertFalse(nft.isFollowing(victimProfileId));

        // 2. Migrate the fake follow. Anyone can run this
        uint256[] memory victimProfileIdArray = new uint256[](1);
        uint256[] memory adversaryProfileIdArray = new uint256[](1);
        uint256[] memory followTokenIdArray = new uint256[](1);

        victimProfileIdArray[0] = victimProfileId;       // 1
        adversaryProfileIdArray[0] = adversaryProfileId; // 3659
        followTokenIdArray[0] = followTokenId;           // 42

        hub.batchMigrateFollows(victimProfileIdArray, adversaryProfileIdArray, followTokenIdArray);

        // 3. The victim is now following the adversary
        assertTrue(nft.isFollowing(victimProfileId));
    }

Tools Used

Manual Review

The follow migration should be reworked to account for the case of "fake" follows. Some ideas are to allow the bulk process to be run by whitelisted accounts, or by the specific token owners. A combination of both may also work, by only migrating tokens that have been minted but not transfered, for example. Or letting users opt-in for an automatic migration to give some ideas.

Additionally log an event with the reason the migration was not executed for a specific profile.

Assessed type

Invalid Validation

#0 - donosonaumczuk

2023-08-08T16:10:33Z

Duplicated from #112 and #106

#1 - c4-sponsor

2023-08-08T17:26:18Z

vicnaum marked the issue as sponsor confirmed

#2 - c4-judge

2023-08-28T16:05:44Z

Picodes marked the issue as duplicate of #112

#3 - c4-judge

2023-08-28T16:05:59Z

Picodes marked the issue as selected for report

#4 - c4-judge

2023-08-28T16:06:06Z

Picodes marked the issue as satisfactory

Awards

474.3819 USDC - $474.38

Labels

bug
grade-a
QA (Quality Assurance)
Q-10

External Links

QA Report

Low Severity Issues

L-01 - Contracts that enable the Guardian on their constructor won't be able to transfer the token later

On the LensProfiles contract, the functions enableTokenGuardian() and DANGER__disableTokenGuardian() have a modifier onlyEOA to only allow EOAs to execute them. The onlyEOA modifier uses OpenZeppelin library to check msg.sender.isContract().

This function only checks that the contract code is zero, which is also true during the constructor phase.

A contract may acquire a token on its constructor function, enable the guardian and continue with its initialization (code length will be zero, so it will pass the check).

Impact

The token will be stuck on the contract.

Proof of Concept

The reason is that the _beforeTokenTransfer() will revert and the guardian can't be disabled. This is because the onlyEOA is applied to DANGER__disableTokenGuardian:

24: import {Address} from '@openzeppelin/contracts/utils/Address.sol';

27:    using Address for address;

50:    modifier onlyEOA() {
51:        if (msg.sender.isContract()) {
52:            revert Errors.NotEOA();
53:        }
54:        _;
55:    }

63:    function DANGER__disableTokenGuardian() external onlyEOA {

77:    function enableTokenGuardian() external onlyEOA {

165:    function _beforeTokenTransfer(
166:        address from,
167:        address to,
168:        uint256 tokenId
169:    ) internal override whenNotPaused {
170:        if (from != address(0) && _hasTokenGuardianEnabled(from)) {
171:            // Cannot transfer profile if the guardian is enabled, except at minting time.
172:            revert Errors.GuardianEnabled();
173:        }

Allow anyone to disable their token guardian regardless of their account type. The DANGER__disableTokenGuardian() function already checks that the token was previously enabled, so this won't affect any other current behaviour.

L-02 - unlink() can be called for any unlinked handles by providing tokenId = 0

TokenHandleRegistry::unlink() can be called with any unlinked handleId, and tokenId = 0.

This is because handleToToken[_handleHash(handle)] will returned an empty struct, tokenPointedByHandle.id will be zero, and the if statement if (tokenPointedByHandle.id != tokenId) { revert RegistryErrors.NotLinked(); } will not enter the condition.

An event will still be emitted in _unlink().

Note: tokenId = 0 is not a valid token value, as it starts on 1.

    function unlink(uint256 handleId, uint256 tokenId) external {
        // We revert here only in the case if both tokens exists and the caller is not the owner of any of them
        if (
            ILensHandles(LENS_HANDLES).exists(handleId) &&
            ILensHandles(LENS_HANDLES).ownerOf(handleId) != msg.sender &&
            ILensHub(LENS_HUB).exists(tokenId) &&
            ILensHub(LENS_HUB).ownerOf(tokenId) != msg.sender
        ) {
            revert RegistryErrors.NotHandleNorTokenOwner();
        }

        RegistryTypes.Handle memory handle = RegistryTypes.Handle({collection: LENS_HANDLES, id: handleId});
        RegistryTypes.Token memory tokenPointedByHandle = handleToToken[_handleHash(handle)];

        // We check if the tokens are (were) linked for the case if some of them doesn't exist
@>      if (tokenPointedByHandle.id != tokenId) { // @audit-info tokenId == 0 for unlinked handles
            revert RegistryErrors.NotLinked();
        }
        _unlink(handle, tokenPointedByHandle);
    }

    function _unlink(RegistryTypes.Handle memory handle, RegistryTypes.Token memory token) internal {
        delete handleToToken[_handleHash(handle)];
        // tokenToHandle is removed too, as the first version linkage is one-to-one.
        delete tokenToHandle[_tokenHash(token)];
@>      emit RegistryEvents.HandleUnlinked(handle, token, block.timestamp);
    }

TokenHandleRegistry.sol#L72-L91 TokenHandleRegistry.sol#L164

Revert if tokenPointedByHandle.id is 0, as it will indicate that the handle is not linked.

L-03 - MetadataURI has no validation for max length

Profile Image URI has a max size of 6000 as defined via MAX_PROFILE_IMAGE_URI_LENGTH = 6000;, but the Metadata URI doesn't have any related validation. This can arise the same problems with large URLs as with the profile image.

Apply a validation for max length to Metadata URI as well.

L-04 - No min length for Lens Handles

Lens Handles has a MAX_HANDLE_LENGTH, but it is lacking a MIN_HANDLE_LENGTH to prevent users from claiming short handles.

Currently on Lens V1, handles with a length shorter than 5 characters can't be minted.

Implement a min handle length for Lens Handles, not dependant on whitelisted registrators.

#0 - c4-judge

2023-08-28T20:38:24Z

Picodes marked the issue as grade-a

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter