ทีมรักษาความปลอดภัย Cobo: ความเสี่ยงที่ซ่อนอยู่และโอกาสในการเก็งกำไรใน ETH Hard Forks

avatar
Cobo Labs
2ปี ที่แล้ว
ประมาณ 4674คำ,ใช้เวลาอ่านบทความฉบับเต็มประมาณ 6นาที
การโจมตีซ้ำส่งผลต่อ forked chains อย่างไร? วิธีป้องกันการโจมตีดังกล่าว

คำนำ

ที่มา: Cobo Global

คำนำ

เมื่อ ETH อัปเกรดระบบฉันทามติของ PoS ห่วงโซ่ ETH ของกลไก PoW ดั้งเดิมก็ประสบความสำเร็จในการฮาร์ดฟอร์กด้วยการสนับสนุนจากบางชุมชน (ต่อไปนี้จะเรียกว่า ETHW) อย่างไรก็ตาม เนื่องจากบางโปรโตคอลบนเครือข่ายไม่ได้เตรียมไว้สำหรับฮาร์ดฟอร์กที่เป็นไปได้ตั้งแต่เริ่มต้นการออกแบบ โปรโตคอลที่เกี่ยวข้องจึงมีความเสี่ยงด้านความปลอดภัยบางอย่างในห่วงโซ่ส้อมของ ETHW และความเสี่ยงด้านความปลอดภัยที่ร้ายแรงที่สุดคือการโจมตีซ้ำ

หลังจากเสร็จสิ้นการฮาร์ดฟอร์ก มีการโจมตีอย่างน้อยสองครั้งโดยใช้กลไกการเล่นซ้ำบนเครือข่ายหลักของ ETHW ได้แก่OmniBridgeเล่นซ้ำการโจมตีและPolygon Bridgeชื่อระดับแรก

ประเภทของการเล่นซ้ำ

ก่อนอื่น เราต้องมีความเข้าใจเบื้องต้นเกี่ยวกับประเภทของ Replay Attack โดยทั่วไป เราแบ่ง Replay Attack ออกเป็น 2 ประเภท ได้แก่และและเล่นซ้ำข้อความที่ลงนามชื่อเรื่องรอง

เล่นซ้ำการทำธุรกรรม

ชื่อเรื่องรอง

เล่นซ้ำข้อความที่ลงนาม

ชื่อระดับแรก

หลักการโจมตีของ OmniBridge และ Polygon Bridge

ชื่อเรื่องรอง

OmniBridge

OmniBridge เป็นบริดจ์ที่ใช้สำหรับการถ่ายโอนสินทรัพย์ระหว่าง xDAI และ ETH mainnet ซึ่งส่วนใหญ่อาศัยตัวตรวจสอบที่กำหนดของบริดจ์เพื่อส่งข้อความข้ามเชนเพื่อให้การถ่ายโอนสินทรัพย์ข้ามลิงก์เสร็จสมบูรณ์ ใน OmniBridge ตรรกะของข้อความยืนยันที่ส่งโดยตัวตรวจสอบจะเป็นดังนี้

function executeSignatures(bytes _data, bytes _signatures) public {
        _allowMessageExecution(_data, _signatures);

        bytes32 msgId;
        address sender;
        address executor;
        uint32 gasLimit;
        uint8 dataType;
        uint256[2] memory chainIds;
        bytes memory data;

        (msgId, sender, executor, gasLimit, dataType, chainIds, data) = ArbitraryMessage.unpackData(_data);

        _executeMessage(msgId, sender, executor, gasLimit, dataType, chainIds, data);
    }

ในฟังก์ชันนี้ ประการแรก ตามการตรวจสอบลายเซ็นของบรรทัด #L2 จะพิจารณาว่าลายเซ็นที่ส่งมานั้นเซ็นชื่อโดยตัวตรวจสอบที่ระบุหรือไม่ จากนั้นข้อความข้อมูลจะถูกถอดรหัสในบรรทัด #L11 จากเนื้อหาที่ถอดรหัสแล้ว ไม่ใช่เรื่องยากที่จะพบว่าฟิลด์ที่ส่งคืนมีฟิลด์ chainId ดังนั้นจึงหมายความว่าข้อความที่เซ็นชื่อไม่สามารถเล่นซ้ำได้ใช่หรือไม่ มาวิเคราะห์กันต่อ

function _executeMessage(
        bytes32 msgId,
        address sender,
        address executor,
        uint32 gasLimit,
        uint8 dataType,
        uint256[2] memory chainIds,
        bytes memory data
    ) internal {
        require(_isMessageVersionValid(msgId));
        require(_isDestinationChainIdValid(chainIds[1]));
        require(!relayedMessages(msgId));
        setRelayedMessages(msgId, true);
        processMessage(sender, executor, msgId, gasLimit, dataType, chainIds[0], data);
    }

จากการติดตามฟังก์ชัน _executeMessage พบว่าฟังก์ชันได้ตรวจสอบความถูกต้องของ chaindId ในบรรทัด #L11 ฟังก์ชัน _isDestinationChainIdValid(uint256 _chainId) ผลตอบแทนภายใน (bool res) {

        return _chainId == sourceChainId();
    }

    function sourceChainId() public view returns (uint256) {
        return uintStorage[SOURCE_CHAIN_ID];
    }

ด้วยการวิเคราะห์ลอจิกของฟังก์ชันที่ตามมาต่อไป จึงไม่ยากที่จะพบว่าการตรวจสอบ chainId จริง ๆ แล้วไม่ได้ใช้ opcode chainId ดั้งเดิมของ evm เพื่อรับ chainId ของ chain เอง แต่ใช้ค่าที่เก็บไว้ในตัวแปร uintStorage โดยตรง ดังนั้นค่านี้จึงชัดเจน ผู้ดูแลระบบเป็นผู้กำหนด ดังนั้นจึงถือได้ว่าข้อความนั้นไม่มีตัวระบุลูกโซ่ ดังนั้นในทางทฤษฎีแล้ว จึงสามารถเล่นซ้ำข้อความที่ลงชื่อแล้วได้

ชื่อเรื่องรอง

Polygon Bridge

เช่นเดียวกับ Omni Bridge Polygon Bridge เป็นสะพานสำหรับการถ่ายโอนสินทรัพย์ระหว่าง Polygon และ ETH mainnet ซึ่งแตกต่างจาก Omni Bridge ตรง Polygon Bridge อาศัยการพิสูจน์บล็อคสำหรับการถอน ตรรกะเป็นดังนี้:

function exit(bytes calldata inputData) external override {
//...ละเว้นตรรกะที่ไม่สำคัญ
        // verify receipt inclusion
        require(
            MerklePatriciaProof.verify(
                receipt.toBytes(),
                branchMaskBytes,
                payload.getReceiptProof(),
                payload.getReceiptRoot()
            ),
            "RootChainManager: INVALID_PROOF"
        );

        // verify checkpoint inclusion
        _checkBlockMembershipInCheckpoint(
            payload.getBlockNumber(),
            payload.getBlockTime(),
            payload.getTxRoot(),
            payload.getReceiptRoot(),
            payload.getHeaderNumber(),
            payload.getBlockProof()
        );

        ITokenPredicate(predicateAddress).exitTokens(
            _msgSender(),
            rootToken,
            log.toRlpBytes()
        );
    }

ผ่านตรรกะของฟังก์ชัน ไม่ใช่เรื่องยากที่จะพบว่าสัญญากำหนดความถูกต้องของข้อความผ่านการตรวจสอบ 2 ครั้งตามลำดับ โดยการตรวจสอบ transactionRoot และ BlockNumber เพื่อให้แน่ใจว่าธุรกรรมเกิดขึ้นจริงใน sub-chain (Ploygon Chain) ครั้งแรก คุณสามารถข้ามการตรวจสอบได้ เนื่องจากใครก็ตามที่คุณสามารถสร้างธุรกรรมรูทของคุณเองผ่านข้อมูลธุรกรรมได้ แต่ไม่สามารถข้ามการตรวจสอบที่สองได้ เนื่องจากคุณสามารถค้นหาได้โดยดูที่ตรรกะของ _checkBlockMembershipInCheckpoint:

function _checkBlockMembershipInCheckpoint(
        uint256 blockNumber,
        uint256 blockTime,
        bytes32 txRoot,
        bytes32 receiptRoot,
        uint256 headerNumber,
        bytes memory blockProof
    ) private view returns (uint256) {
        (
            bytes32 headerRoot,
            uint256 startBlock,
            ,
            uint256 createdAt,

        ) = _checkpointManager.headerBlocks(headerNumber);

        require(
            keccak256(
                abi.encodePacked(blockNumber, blockTime, txRoot, receiptRoot)
            )
                .checkMembership(
                blockNumber.sub(startBlock),
                headerRoot,
                blockProof
            ),
            "RootChainManager: INVALID_HEADER"
        );
        return createdAt;
    }

headerRoot ที่เกี่ยวข้องถูกดึงมาจากสัญญา _checkpointManager ตามตรรกะนี้ เราจะดูที่ตำแหน่งที่ _checkpointManager ตั้งค่า headerRoot

function submitCheckpoint(bytes calldata data, uint[3][] calldata sigs) external {
        (address proposer, uint256 start, uint256 end, bytes32 rootHash, bytes32 accountHash, uint256 _borChainID) = abi
            .decode(data, (address, uint256, uint256, bytes32, bytes32, uint256));
        require(CHAINID == _borChainID, "Invalid bor chain id");

        require(_buildHeaderBlock(proposer, start, end, rootHash), "INCORRECT_HEADER_DATA");

        // check if it is better to keep it in local storage instead
        IStakeManager stakeManager = IStakeManager(registry.getStakeManagerAddress());
        uint256 _reward = stakeManager.checkSignatures(
            end.sub(start).add(1),
            /**  
                prefix 01 to data 
                01 represents positive vote on data and 00 is negative vote
                malicious validator can try to send 2/3 on negative vote so 01 is appended
             */
            keccak256(abi.encodePacked(bytes(hex"01"), data)),
            accountHash,
            proposer,
            sigs
        );
//....ตรรกะที่เหลือละเว้น

ไม่ยากที่จะพบว่าในบรรทัดรหัส #L2 ข้อมูลลายเซ็นตรวจสอบเฉพาะ borChianId แต่ไม่ใช่ chainId ของ chain เอง เนื่องจากข้อความถูกลงนามโดยผู้เสนอที่ระบุในสัญญา ดังนั้น ในทางทฤษฎีผู้โจมตีจึงสามารถ เล่นซ้ำลายเซ็นข้อความของผู้เสนอบนเชนที่แยกออก ส่ง headerRoot ตามกฎหมาย จากนั้นใช้ Polygon Bridge เพื่อเรียกใช้ฟังก์ชัน exit ในเชน ETHW และส่งหลักฐาน Merkle ธุรกรรมที่เกี่ยวข้อง จากนั้นการถอนจะสำเร็จและผ่านการตรวจสอบ HeaderRoot

ยกตัวอย่างที่อยู่ 0x7dbf18f679fa07d943613193e347ca72ef4642b9 ที่อยู่นี้ได้เสร็จสิ้นการเก็งกำไรบนห่วงโซ่ ETHW ผ่านขั้นตอนต่อไปนี้

  1. ก่อนอื่น พึ่งพาความสามารถของธนบัตรในการถอนเหรียญออกจากการแลกเปลี่ยนเครือข่ายหลัก

  2. ฝากเหรียญบนโซ่รูปหลายเหลี่ยมผ่านฟังก์ชั่นฝากสำหรับสะพานรูปหลายเหลี่ยม

  3. เครือข่ายหลัก ETH เรียกใช้ฟังก์ชันทางออกของ Polygon Bridge เพื่อถอนเหรียญ

  4. คัดลอกและแยก headerRoot ที่ส่งโดยผู้เสนอ ETH mainnet

  5. เล่นซ้ำข้อความลายเซ็นของผู้เสนอที่แยกออกมาในขั้นตอนก่อนหน้าใน ETHW

  6. ชื่อระดับแรก

ทำไมสิ่งนี้ถึงเกิดขึ้น?

จากสองตัวอย่างที่วิเคราะห์ข้างต้น ไม่ใช่เรื่องยากที่จะพบว่าโปรโตคอลทั้งสองนี้พบการโจมตีซ้ำบน ETHW เนื่องจากตัวโปรโตคอลเองไม่มีการป้องกันการเล่นซ้ำที่ดี ส่งผลให้สินทรัพย์ที่สอดคล้องกับโปรโตคอลถูกเจาะเข้าไปในห่วงโซ่ที่แยกออกจากกัน . อย่างไรก็ตาม เนื่องจากสะพานทั้งสองไม่รองรับ ETHW fork chain ผู้ใช้จึงไม่ได้รับความสูญเสียใดๆ แต่สิ่งที่เราต้องพิจารณาคือเหตุใดสะพานทั้งสองนี้จึงไม่เพิ่มมาตรการป้องกันการเล่นซ้ำตั้งแต่เริ่มต้นการออกแบบ อันที่จริง เหตุผลนั้นง่ายมาก เพราะไม่ว่าจะเป็น OmniBridge หรือ Polygon Bridge สถานการณ์แอปพลิเคชันที่พวกเขาออกแบบนั้นมีลักษณะเดียวและใช้เพื่อถ่ายโอนสินทรัพย์ไปยังเชนที่เกี่ยวข้องที่กำหนดโดยตัวมันเองเท่านั้น ไม่มีแผนสำหรับหลาย ๆ การปรับใช้แบบลูกโซ่ ดังนั้นจึงไม่มีการป้องกันการเล่นซ้ำ ไม่มีผลกระทบด้านความปลอดภัยต่อตัวโปรโตคอล

ในทางตรงกันข้าม ผู้ใช้บน ETHW เนื่องจากบริดจ์เหล่านี้ไม่รองรับสถานการณ์แบบหลายเชน หากผู้ใช้ใช้งานบน ETHW fork chain พวกเขาจะได้รับการโจมตีแบบเล่นข้อความซ้ำบนเครือข่ายหลักของ ETH แทน

ยกตัวอย่าง UniswapV2 ปัจจุบันอยู่ในสัญญารวมของ UnswapV2 มีฟังก์ชันอนุญาต และมีตัวแปร PERMIT_TYPEHASH ในฟังก์ชันนี้ ซึ่งมีตัวแปร DOMAIN_SEPARATOR

function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external {
        require(deadline >= block.timestamp, UniswapV2: EXPIRED);
        bytes32 digest = keccak256(
            abi.encodePacked(
                \x19\x01,
                DOMAIN_SEPARATOR,
                keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline))
            )
        );
        address recoveredAddress = ecrecover(digest, v, r, s);
        require(recoveredAddress != address(0) && recoveredAddress == owner, UniswapV2: INVALID_SIGNATURE);
        _approve(owner, spender, value);
    }

ตัวแปรนี้ถูกกำหนดครั้งแรกใน EIP712 ซึ่งมี chainId ซึ่งรวมถึงการป้องกันการเล่นซ้ำที่เป็นไปได้สำหรับสถานการณ์แบบหลายเชนที่จุดเริ่มต้นของการออกแบบ แต่ตามตรรกะของสัญญาพูล uniswapV2 จะเป็นดังนี้:

constructor() public {
       uint chainId;
       assembly {
           chainId := chainid
       }
       DOMAIN_SEPARATOR = keccak256(
           abi.encode(
               keccak256(EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)),
               keccak256(bytes(name)),
               keccak256(bytes(1)),
               chainId,
               address(this)
           )
       );
   }

ชื่อระดับแรก

ข้อควรระวังในช่วงเริ่มต้นของการออกแบบโปรโตคอล

ชื่อระดับแรก

ชื่อเรื่องรอง

ผลกระทบต่อผู้ใช้

ชื่อเรื่องรอง

ความหมายสำหรับการแลกเปลี่ยนและผู้ดูแล

สรุป

สรุป

ด้วยการพัฒนาสถานการณ์แบบหลายเชน การโจมตีแบบเล่นซ้ำได้ค่อยๆ กลายเป็นวิธีโจมตีกระแสหลักจากระดับทฤษฎี นักพัฒนาควรพิจารณาการออกแบบโปรโตคอลอย่างรอบคอบ เมื่อออกแบบกลไกลายเซ็นข้อความ ให้เพิ่มปัจจัยต่างๆ เช่น chainId เป็นเนื้อหาลายเซ็นให้มากที่สุดเท่าที่จะเป็นไปได้ เป็นไปได้ และปฏิบัติตามแนวทางปฏิบัติที่ดีที่สุดที่เกี่ยวข้องเพื่อป้องกันการสูญเสียทรัพย์สินของผู้ใช้

บทความต้นฉบับ, ผู้เขียน:Cobo Labs。พิมพ์ซ้ำ/ความร่วมมือด้านเนื้อหา/ค้นหารายงาน กรุณาติดต่อ report@odaily.email;การละเมิดการพิมพ์ซ้ำกฎหมายต้องถูกตรวจสอบ

ODAILY เตือนขอให้ผู้อ่านส่วนใหญ่สร้างแนวคิดสกุลเงินที่ถูกต้องและแนวคิดการลงทุนมอง blockchain อย่างมีเหตุผลและปรับปรุงการรับรู้ความเสี่ยงอย่างจริงจัง สำหรับเบาะแสการกระทำความผิดที่พบสามารถแจ้งเบาะแสไปยังหน่วยงานที่เกี่ยวข้องในเชิงรุก

การอ่านแนะนำ
ตัวเลือกของบรรณาธิการ