2022starCTF——TreasureHunter

admin 2022年4月20日10:45:51评论70 views字数 16531阅读55分6秒阅读模式
周末 *CTF 的一道智能合约题,继承之前RealWorldTreasure Hunter
合约还蛮长的,附件放在https://github.com/JayxV/Van1sh_Utility/tree/main/2022%20starCTF了。

不过和2022realword的那道题并没有差的很多,不过由于还没有复现过,所以在比赛的时候也是从零开始,不过还是站在巨人的肩膀上啦,首先根据https://r3kapig.com/writeup/20220125-rwctf4/#treasure-hunter这份wp,把题目的整个数据结构理清楚了。


2022starCTF——TreasureHunter

一、 题目结构

2022starCTF——TreasureHunter

1.1 Merkle Tree


题目构建了这样一个Merkle Tree,首先每一片叶子上面的数据结构是 [addressvalue]
    struct Leaf {
        address key;
        uint8 value;
    }
 
然后每一片叶子的哈希计算规则为:如果value0,哈希则为0,如果value不为0,则将addressvalue进行拼接后进行哈希
    function calcLeaf(Leaf memory a) internal pure returns (bytes32) {
        if (a.value == 0) {
            return 0;
        } else {
            return keccak256(abi.encode(a.key, a.value));
        }
    }

两片叶子merge的方法:如果有一片叶子是0,则直接返回另一片叶子的哈希,否则将左叶子的哈希和右叶子的哈希拼接再哈希
   
function merge(bytes32 l, bytes32 r) internal pure returns (bytes32) {
        if (l == 0) {
            return r;
        } else if (r == 0) {
            return l;
        } else {
            return keccak256(abi.encode(l, r));
        }
    }
 
 
根哈希的生成方式【重点】
计算根哈希的时候需要传入一个类似于opcode _proofs,然后计算所需的叶子节点 _leaves,以及根节点 _root
    function calcRoot(
        bytes32[] memory _proofs,
        Leaf[] memory _leaves,
        bytes32 _root
    )
 
首先会检查,传入的叶子节点中的地址必须是从小到大的。
require(checkGroupSorted(_leaves));

    function checkGroupSorted(Leaf[] memory _leaves) internal pure returns (bool) {
        require(_leaves.length >= 1);
        uint160 temp = 0;
        for (uint256 i = 0; i < _leaves.length; i++) {
            if (temp >= uint160(_leaves[i].key)) {
                return false;
            }
            temp = uint160(_leaves[i].key);
        }
        return true;
    }

 
然后初始化两个数组模拟栈,用于存放叶子的 Keys  Values
        uint160[] memory stackKeys = new uint160[](SMT_STACK_SIZE);
        bytes32[] memory stackValues = new bytes32[](SMT_STACK_SIZE);
        uint256 proofIndex = 0;
        uint256 leaveIndex = 0;
        uint256 stackTop = 0;
然后开始根据opcode _proofs 对数据进行处理。
三个分支,
如果opcode 0x4c,就类似于将一个叶子节点压栈:opcode下标加一(是一个单“slot”指令),先检查会不会溢出,然后往stackKeys存入叶子的key,往stackValues存入叶子的哈希,stackTop(栈顶)加一,leaveIndex (当前叶子下标)加一
if (uint256(_proofs[proofIndex]) == 0x4c) {
    proofIndex++;
    require(stackTop < SMT_STACK_SIZE);
    require(leaveIndex < _leaves.length);
    stackKeys[stackTop] = uint160(_leaves[leaveIndex].key);
    stackValues[stackTop] = calcLeaf(_leaves[leaveIndex]);
    stackTop++;
    leaveIndex++;
}
如果opcode 0x50,是将叶子节点和opcode传入的哈希进行一个merge操作:首先proofIndex++,然后检查栈是非空的(因为要取值),然后检查proofIndex + 2 <= _proofs.length,因为这里是一个三“slot”指令,接着从指令的第二个slot获取height,从指令的第三个slot获取用于merge的哈希 currentProof,并且这个currentProof不能够等于根哈希。然后获取用于merge的叶子节点的key的在height处的比特,如果是1,那么叶子节点作为右叶子进行merge,如果是0,那么叶子节点作为左叶子进行merge。最后在stackKeys中会将该叶子节点原本的keyheight处及其低位都清零,只留下高位,也就是前缀。
    // 获取特定位置的bit
    function getBit(uint160 key, uint256 height) internal pure returns (uint256) {
        require(height <= DEPTH);
        return (key >> height) & 1;
    }
    
    
    // 低位清零
    function parentPath(uint160 key, uint256 height) internal pure returns (uint160) {
        require(height <= DEPTH);
        return copyBit(key, height + 1);
    }
    
    function copyBit(uint160 key, uint256 height) internal pure returns (uint160) {
        require(height <= DEPTH);
        return ((key >> height) << height);
    }
 
else if (uint256(_proofs[proofIndex]) == 0x50) { 
    proofIndex++; 
    require(stackTop != 0); 
    require(proofIndex + 2 <= _proofs.length); 
 
    uint256 height = uint256(_proofs[proofIndex++]); 
    bytes32 currentProof = _proofs[proofIndex++]; 
    require(currentProof != _root); 
    if (getBit(stackKeys[stackTop - 1], height) == 1) { 
        stackValues[stackTop - 1] = merge(currentProof, stackValues[stackTop - 1]); 
    } else { 
        stackValues[stackTop - 1] = merge(stackValues[stackTop - 1], currentProof); 
    } 
stackKeys[stackTop - 1] = parentPath(stackKeys[stackTop - 1], height);
 
如果opcode0x48,会将栈里的两个叶子节点进行merge,是一个双“slot”指令,指令的第二个slot同样是 height。首先分别获取两个叶子节点keyheight处的bit(设靠近栈顶的为a,靠近栈底的为b),然后更新,仅留下前缀。接着一个判断,要求前缀相同,height处的比特不同。然后获取两个叶子节点的哈希,如果aheight处的bit1,那么a作为左叶子,否则b作为左叶子,最后merge,原本两个叶子出栈,新的叶子入栈(key是前缀,valuemerge后的哈希),所以栈顶减一。
else if (uint256(_proofs[proofIndex]) == 0x48) {
     proofIndex++;
     require(stackTop >= 2);
     require(proofIndex < _proofs.length);
     uint256 height = uint256(_proofs[proofIndex++]);
     uint256 aSet = getBit(stackKeys[stackTop - 2], height);
     uint256 bSet = getBit(stackKeys[stackTop - 1], height);
     stackKeys[stackTop - 2] = parentPath(stackKeys[stackTop - 2], height);
     stackKeys[stackTop - 1] = parentPath(stackKeys[stackTop - 1], height);
     require(stackKeys[stackTop - 2] == stackKeys[stackTop - 1] && aSet != bSet);

     if (aSet == 1) {
          stackValues[stackTop - 2] = merge(
          stackValues[stackTop - 1],
          stackValues[stackTop - 2]
        );
       } else {
           stackValues[stackTop - 2] = merge(
           stackValues[stackTop - 2],
           stackValues[stackTop - 1]
           );
       }
       stackTop -= 1;

 
最后要求,opcode走完后,每一个传入的叶子节点都用于根哈希的计算,栈里只剩下一个叶子,也就是根,然后返回value,也就是根哈希
require(leaveIndex == _leaves.length);
require(stackTop == 1);
return stackValues[0];
另外还有插入和删除函数,合并在了update里,
首先会验证插入前这棵树是不是合法的,然后进行更新,并计算相应的根节点,具体更新规则则依据传入的opcode_proofs
    function update(
        bytes32[] memory _proofs,
        Leaf[] memory _nextLeaves,
        Leaf[] memory _prevLeaves,
        bytes32 _prevRoot
    ) internal pure returns (bytes32) {
        require(verify(_proofs, _prevLeaves, _prevRoot), "update proof not valid");
        return calcRoot(_proofs, _nextLeaves, _prevRoot);
    }
 
如果要更新单个叶子的状态,两种,插入和删除,如果是删除,就将value=0,这样进行根哈希计算的时候它就不会被引入。如果插入,则value=1
   function updateSingleTarget(
        bytes32[] memory _proofs,
        address _target,
        bytes32 _prevRoot,
        Method _method
    ) internal pure returns (bytes32) {
        Leaf[] memory nextLeaves = new Leaf[](1);
        Leaf[] memory prevLeaves = new Leaf[](1);
        nextLeaves[0] = Leaf({key: _target, value: uint8(_method) ^ 1});
        prevLeaves[0] = Leaf({key: _target, value: uint8(_method)});
        return update(_proofs, nextLeaves, prevLeaves, _prevRoot);
    }
 
2022starCTF——TreasureHunter
 

1.2 TreasureHunter 


接下来我们看到这道题,获取flag的方式需要打开宝箱
    function openTreasureChest() public {
        require(haveKey[msg.sender] && haveTreasureChest[msg.sender]);
        solved = true;
        emit OpenTreasureChest(msg.sender);
    }
打开宝箱就需要找到宝箱和找到钥匙
找到宝箱则需要smtMode == SMT.Mode.WhiteList,这是刚开始是满足的,然后要求team的长度大于4,并通过验证SMT.verifyByMode(_proofs, team, root, smtMode) ,这个验证就是team里的叶子节点都在这颗树上。满足要求的话就会 smtMode = SMT.Mode.BlackList;
    function pickupTreasureChest(bytes32[] memory _proofs) public {
        require(smtMode == SMT.Mode.WhiteList, "not whitelist mode");
        require(team.length >= 4);
        require(
            SMT.verifyByMode(_proofs, team, root, smtMode),
            "hunter hasn't found the treasure chest"
        );
        haveTreasureChest[msg.sender] = true;
        smtMode = SMT.Mode.BlackList;
        emit PickupTreasureChest(msg.sender);
    }
然后是找到钥匙,需要满足 smtMode == SMT.Mode.BlackList,所以需要先找到宝箱再找到钥匙,同样要求team长度大于4,这次呀通过的验证SMT.verifyByMode(_proofs, team, root, smtMode),也还是这个,但是smtMode变了。所以这次的要求是team里的四片叶子都不在这颗树上。
    function findKey(bytes32[] memory _proofs) public {
        require(smtMode == SMT.Mode.BlackList, "not blacklist mode");
        require(team.length >= 4);
        require(SMT.verifyByMode(_proofs, team, root, smtMode), "hunter has fallen into a trap");
        haveKey[msg.sender] = true;
        smtMode = SMT.Mode.WhiteList;
        emit FindKey(msg.sender);
    }
那么如果增加team的成员呢,有一个enter方法可以往里面加新成员,就是合约的调用者,
    function enter(bytes32[] memory _proofs) public {
        require(haveKey[msg.sender] == false);
        require(checkteam());
        team.push(msg.sender);
        root = SMT.updateSingleTarget(_proofs, msg.sender, root, SMT.Method.Insert);
    }
不过在加新成员的时候,他会检查之前的树是否合法,然后才会将你加入,而怎么检查呢?整个合约完全没有存储原来的结构,仅仅保存了一个根节点,所以全部依赖_proofs,也就是opcode了。

2022starCTF——TreasureHunter


2022starCTF——TreasureHunter

二 、 解题思路

2022starCTF——TreasureHunter


所以我们整个解题步骤就是构造opcode,往原来的树上插入四个账户,因为插入的四个账户,根节点的计算肯定是跟他们有关的,所以应该可以直接找到宝箱。然后我们再去findkey,这个时候四个账户的哈希都不起作用了,所以我们还需要构造opcode,去绕过findkey这边的验证。

2.1 enter


首先我们看看如何调用enterteam添加用户,在这之前,首先题目通过init是创建好了一颗merkle True的,
根据init中的opcode画出来大概是这样子的(上面wp链接中的),运行后我们得到root0xe2e8ebf79be9c50374592f83db1c4c82e4d97b4dae6dc26332d259289467ff8e
如果我们想调用enter,则会调用 SMT.updateSingleTarget(_proofs, msg.sender, root, SMT.Method.Delete);
    function updateSingleTarget(
        bytes32[] memory _proofs,
        address _target,
        bytes32 _prevRoot,
        Method _method
    ) internal pure returns (bytes32) {
        Leaf[] memory nextLeaves = new Leaf[](1);
        Leaf[] memory prevLeaves = new Leaf[](1);
        nextLeaves[0] = Leaf({key: _target, value: uint8(_method) ^ 1});
        prevLeaves[0] = Leaf({key: _target, value: uint8(_method)});
        return update(_proofs, nextLeaves, prevLeaves, _prevRoot);
    }
其中,_proofs 就是opcode了,可控;然后msg.sender就是调用合约的用户,可控;root是当前根哈希,不可控;SMT.Method.Delete是当前状态,此处为delete,因为此时msg.sender还不在树上。在updateSingleTarget的时候,首先会以msg.senderdelete的状态(这样就不会影响到原来的树了)检查原来的树,然后就会对树进行更新,而msg.sender具体插入的位置则由opcode决定。
那么我们核心的任务就是先过update里的验证先。
这里,根据先前的wp,我们拿到了生成root哈希的两个哈希,分别是0xe9f810898db8dc62342eaa122fd26525362f2b70bd462edef6e4e34093d66c170xba13a52ab72064627701ac75ab564f7e786d093c655849458536cc689abdf8e2
2022starCTF——TreasureHunter
然后由于msg.sender在验证的时候是不参与计算的,所以其实我们直接传入这两个哈希,然后分别调用两次0x50就好了。opcode
["0x000000000000000000000000000000000000000000000000000000000000004c",
 "0x0000000000000000000000000000000000000000000000000000000000000050",
 "0x000000000000000000000000000000000000000000000000000000000000009c",
 "0xe9f810898db8dc62342eaa122fd26525362f2b70bd462edef6e4e34093d66c17",
 "0x0000000000000000000000000000000000000000000000000000000000000050",
 "0x000000000000000000000000000000000000000000000000000000000000009f",
 "0xba13a52ab72064627701ac75ab564f7e786d093c655849458536cc689abdf8e2"]
我们首先将msg.sender这个叶子压栈,然后调用0x50,根据opcode,首先会取msg.sender 0x9c处的比特,然后决定是msg.sender的值是作为左叶子还是右叶子,不过此时是无所谓的,因为msg.sendervalue0,并不会影响merge后的哈希。然后再进行一个0x50,高度取的是msg.sender0x9f处(也就是最高处)的比特,这里我们有进行构造,我们的msg.sender最高比特都是0,所以此时我们的0xe9f810....是作为左叶子,0xba13a.....作为右叶子,然后merge,得到我们的root,从而通过验证。于是我们如下merkle tree
2022starCTF——TreasureHunter
 
注:关于第一个高度的选取,这里我们需要使得插入的msg.sender是左叶子,我们后续会说明;第二个高度不一定非要是0x9f,只要该处比特是0,保证merge的顺序就好了。
通过验证后会更新树,得到新的hash,这里我们用的账户地址是0x0051359C4A49AE35034c693Bb423343874964bD9,所以计算出来的叶子哈希为0x9b1d5789a62aaf2914f81251c7fe468b731d0d69587149c02fa455d2f607d540merge后得到0x423b9e9e1b3a1cfbd4c7d075391785c44def9c2884281cc63e0a78fcc3bb7f66,再次merge后得到新的根哈希0xf712cc750b9acd5fc76c58b7302bef35c4890937e08702f0d8202f289c8ae8c3
 
2022starCTF——TreasureHunter
 
验证正确。
于是我们有新的merkle Tree
2022starCTF——TreasureHunter
 
于是我们故技重施,利用0x423b9e9e1b3  0xba13a52ab7 ,再次插入一个用户
opcode
["0x000000000000000000000000000000000000000000000000000000000000004c",
 "0x0000000000000000000000000000000000000000000000000000000000000050",
 "0x000000000000000000000000000000000000000000000000000000000000009a",
 "0x423b9e9e1b3a1cfbd4c7d075391785c44def9c2884281cc63e0a78fcc3bb7f66",
 "0x0000000000000000000000000000000000000000000000000000000000000050",
 "0x000000000000000000000000000000000000000000000000000000000000009f",
 "0xba13a52ab72064627701ac75ab564f7e786d093c655849458536cc689abdf8e2"]
这一次我们用的地址是0x06F07E646D6724874e72FCE7f3AC534cfbEC754a9a处的比特为1,叶子哈希为0xdea1d3dc0931a8116ca4692f0a3d6387eebbd4ba4e95d671b154412eade4d22cmerge后哈希为0x4e951b5440112027d135d0f1dc97bff8127bec7439f8a54ff6a6b6a164764c64,新的根哈希为0xb8bb540e2e9243c6d081375dfb652f6a87fcfb246c0a461b315e3e530bbbf0f8
验证一下
2022starCTF——TreasureHunter
 
所以新的树是这个样子
2022starCTF——TreasureHunter
 
继续第三个,
["0x000000000000000000000000000000000000000000000000000000000000004c",
 "0x0000000000000000000000000000000000000000000000000000000000000050",
 "0x000000000000000000000000000000000000000000000000000000000000009e",
 "0x4e951b5440112027d135d0f1dc97bff8127bec7439f8a54ff6a6b6a164764c64",
 "0x0000000000000000000000000000000000000000000000000000000000000050",
 "0x000000000000000000000000000000000000000000000000000000000000009f",
 "0xba13a52ab72064627701ac75ab564f7e786d093c655849458536cc689abdf8e2"]
地址用的是0x49Bb1e658D6EE866215D11037A6De1712Ed81B830x9e处还是1,叶子哈希为0x111b8a732fb70d95860bf6aa916f2973cf45ab797aac6af673dd0cf1f9918c71merge后是0x4b3f7ee71efd14747b6620f1919bcdc78724d8a862e987596a1179a6b460be05,根哈希为0xd7266ab3d5607bd49580f90162ff62ce4ac844488a07d38926c0511b687dd6e9
2022starCTF——TreasureHunter
 
2022starCTF——TreasureHunter
 
最后一个
["0x000000000000000000000000000000000000000000000000000000000000004c", 
"0x0000000000000000000000000000000000000000000000000000000000000050", 
"0x000000000000000000000000000000000000000000000000000000000000009e",
"0x4b3f7ee71efd14747b6620f1919bcdc78724d8a862e987596a1179a6b460be05",
"0x0000000000000000000000000000000000000000000000000000000000000050",
"0x000000000000000000000000000000000000000000000000000000000000009f",
"0xba13a52ab72064627701ac75ab564f7e786d093c655849458536cc689abdf8e2"]
地址为0x6a95Ad9945396aF307B6d0D99F5382F8C781B4f4,0x9c处为1,叶子哈希为0x34de1d313ec0366dafd9277ff42bea64676a6b426da826971d07d836ca7cf583merge后为0x03f38c848024ba3006373acb4175da682ce9d4e1ade10e0472919f30f664b1e4,根哈希为0x1b70984cd043483aee663bdaa93a102faaadf0d7b5c429830cf3b5cc14d41429
2022starCTF——TreasureHunter
 
2022starCTF——TreasureHunter
 
至于为什么要弄成这样单边的,接下下来找宝箱就知道了。
2022starCTF——TreasureHunter


2.2 pickupTreasureChest


现在我们已经把四个用户加入进去了,于是调用pickupTreasureChest,他会调用SMT.verifyByMode(_proofs, team, root, smtMode),我们需要构造一下opcode使得计算出来的根哈希等于0x1b70984cd043483ae
注意到calcroot的第一步就是检查叶子key的大小关系,所以我们之前是从小到大依次使用地址的,就是为了过这个检查
0x0051359C4A49AE35034c693Bb423343874964bD9 
0x06F07E646D6724874e72FCE7f3AC534cfbEC754a 
0x49Bb1e658D6EE866215D11037A6De1712Ed81B83 
0x6a95Ad9945396aF307B6d0D99F5382F8C781B4f4
然后这里我们第一个就是要将team1压栈,然后是调用0x50,往opcode里塞一个0xe9f810898,这样能merge0x423b9e9e1,接着将team2压栈,再调用0x48,将栈里的叶子和team2进行merge,注意到,先前提到,在0x48中对高度的取值比较严格,
if (uint256(_proofs[proofIndex]) == 0x48) { 
     proofIndex++; 
     require(stackTop >= 2); 
     require(proofIndex < _proofs.length); 
     uint256 height = uint256(_proofs[proofIndex++]); 
     uint256 aSet = getBit(stackKeys[stackTop - 2], height); 
     uint256 bSet = getBit(stackKeys[stackTop - 1], height); 
     stackKeys[stackTop - 2] = parentPath(stackKeys[stackTop - 2], height); 
     stackKeys[stackTop - 1] = parentPath(stackKeys[stackTop - 1], height); 
     require(stackKeys[stackTop - 2] == stackKeys[stackTop - 1] && aSet != bSet); 
 
     if (aSet == 1) { 
         stackValues[stackTop - 2] = merge( 
         stackValues[stackTop - 1], 
         stackValues[stackTop - 2] 
         ); 
      } else { 
         stackValues[stackTop - 2] = merge( 
         stackValues[stackTop - 2], 
         stackValues[stackTop - 1] 
         ); 
}
height处两个地址的比特必须是不一样的,并且比hight高的比特位必须是一样的,所以我们检查一下地址0x0051359C4A49AE35034c693Bb423343874964bD90x06F07E646D6724874e72FCE7f3AC534cfbEC754a
2022starCTF——TreasureHunter
 
可以看到,在0x9a处两个地址开始不一样,所以0x48的时候,高度要填0x9a,然后,显然,由于前面要求的地址必须从小到大,aset肯定是0,也就是意味着,先入栈的叶子必然是左叶子,这就是为什么我们之前把树往左边构造
以此类推,高度分别需要填0x9a,0x9e,0x9e,最后我们在进行一次0x50,和0xba13a52ab72 merge一下,最终opcode
["0x000000000000000000000000000000000000000000000000000000000000004c", 
"0x0000000000000000000000000000000000000000000000000000000000000050", 
"0x000000000000000000000000000000000000000000000000000000000000009c", 
"0xe9f810898db8dc62342eaa122fd26525362f2b70bd462edef6e4e34093d66c17", 
"0x000000000000000000000000000000000000000000000000000000000000004c", 
"0x0000000000000000000000000000000000000000000000000000000000000048",
"0x000000000000000000000000000000000000000000000000000000000000009a",
"0x000000000000000000000000000000000000000000000000000000000000004c", 
"0x0000000000000000000000000000000000000000000000000000000000000048", 
"0x000000000000000000000000000000000000000000000000000000000000009e", 
"0x000000000000000000000000000000000000000000000000000000000000004c", 
"0x0000000000000000000000000000000000000000000000000000000000000048", 
"0x000000000000000000000000000000000000000000000000000000000000009e", 
"0x0000000000000000000000000000000000000000000000000000000000000050", 
"0x000000000000000000000000000000000000000000000000000000000000009f", 
"0xba13a52ab72064627701ac75ab564f7e786d093c655849458536cc689abdf8e2"]
 
2022starCTF——TreasureHunter
 
成功。
2022starCTF——TreasureHunter


2.3 findkey


findkey中,四片叶子都不参与计算,所以我们显然要构造opcode,然后利用0x50,将用于生成root 0x03f38c848024 0xba13a52ab 塞进去就好了。
所以我们先把叶子压进去,与先前一样利用0x48把他们都merge了,此时stackValue里的值都还是0,然后我们调用两次0x500x03f38c848024  0xba13a52ab 分别放入,最后就能计算出我们的目标root了。最终opcode
["0x000000000000000000000000000000000000000000000000000000000000004c", 
"0x000000000000000000000000000000000000000000000000000000000000004c", 
"0x0000000000000000000000000000000000000000000000000000000000000048", 
"0x000000000000000000000000000000000000000000000000000000000000009a", 
"0x000000000000000000000000000000000000000000000000000000000000004c", 
"0x0000000000000000000000000000000000000000000000000000000000000048", 
"0x000000000000000000000000000000000000000000000000000000000000009e", 
"0x000000000000000000000000000000000000000000000000000000000000004c", 
"0x0000000000000000000000000000000000000000000000000000000000000048", 
"0x000000000000000000000000000000000000000000000000000000000000009e", 
"0x0000000000000000000000000000000000000000000000000000000000000050", 
"0x000000000000000000000000000000000000000000000000000000000000009c", 
"0x03f38c848024ba3006373acb4175da682ce9d4e1ade10e0472919f30f664b1e4", 
"0x0000000000000000000000000000000000000000000000000000000000000050", 
"0x000000000000000000000000000000000000000000000000000000000000009f", 
"0xba13a52ab72064627701ac75ab564f7e786d093c655849458536cc689abdf8e2"]
 
2022starCTF——TreasureHunter
 
成功。
随后调用一下OpenTreasureChest即可
2022starCTF——TreasureHunter
 
2022starCTF——TreasureHunter
 

2.4 完整exp


来自天璇merak-zbr,见附件(文章开头)

2022starCTF——TreasureHunter

原文始发于微信公众号(山石网科安全技术研究院):2022starCTF——TreasureHunter

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2022年4月20日10:45:51
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   2022starCTF——TreasureHunterhttp://cn-sec.com/archives/929273.html

发表评论

匿名网友 填写信息