切片式SMC的例题解析及其自动化反混淆

admin 2023年9月21日12:47:57评论21 views字数 15898阅读52分59秒阅读模式
月初的2023羊城杯初赛遇到了一道很神奇的逆向题babyobfu,题中使用了非常多的SMC保护函数原本的代码,在原代码运行前对代码进行解密,在原代码运行结束后对代码进行重加密,这种切片式保护能很好的防止静态分析,而原本代码中100层的循环加密也阻挠了一些动态分析选手。
因为对这种切片式加密方式很感兴趣,故赛后研究了一下这个题目,并手搓了一个IDAPython脚本来自动化反混淆这种切片式SMC。

切片式SMC解析

SMC(self-Modifying Code 自修改代码),就是在真正执行某一段代码时,程序会对自身的该段代码进行自修改,只有在修改后的代码才是可汇编、可执行的。在程序未对该段代码进行修改之前,在静态分析状态下,均是不可读的字节码,IDA之类的反汇编器无法识别程序的正常逻辑。
以babyobfu为例,切片式SMC分为四个阶段:初始化、密钥设置、代码解密、代码重加密。其中初始化阶段仅在原程序每个函数开始时进行,用于初始化SMC过程中需要的局部变量数组;密钥设置阶段用于对密钥数组进行更改,不一定出现在每个代码解密阶段前;代码解密阶段与代码重加密阶段包裹在被保护的代码两端,通过与密钥异或保证了代码的正常运行与隐蔽性并存的需求。
以下以main函数里的第一段SMC混淆为例进行解析。

初始化阶段

切片式SMC的例题解析及其自动化反混淆

该阶段主要由两个memset函数(memset(str, c, n))构成,主要用0填充了首地址分别为[rbp-47h][rbp-0C4h]的两个局部变量数组,大小分别为0x1F0x7C,从后续阶段可知两个数组可命名为statedata。即:
memset(state, 00x1F); // [rbp-47h]
memset(data, 00x7C); // [rbp-0C4h]

密钥设置阶段

切片式SMC的例题解析及其自动化反混淆

该阶段主要是smc_keyset函数(0x4035C0),可以看到该函数接收了五个参数,其中除了常量以外也有上一阶段中的两个局部变量数组。该函数的具体实现:

切片式SMC的例题解析及其自动化反混淆

该函数通过a1判断是否进行密钥更改、a2限制大小,将a4[a3[i]]a5异或。由函数调用前的传参可知,a1为上一阶段中初始化的state数组的首字节(根据栈偏移确认索引),a4为上一阶段中初始化的data数组(即密钥),a3为.data段中的一段已知数据,a2a5都是常数。即:
smc_keyset(state[0], 0x1E, (int *)&unk_4062B0, data, 0x109CF92E);

代码解密阶段

切片式SMC的例题解析及其自动化反混淆

该阶段主要是smc_decrypt函数(0x403630),将上面的state设置为1以后,该函数接收了三个参数,其中除了常量以外还调用到了data数组。该函数的具体实现:

切片式SMC的例题解析及其自动化反混淆

前面getpagesizemprotect两个函数用于暂时更改部分地址的权限,具体为代码段[a1, a1+a2)范围(a1为需要恢复代码的首地址,a2为代码长度)增加可写权限,以便SMC对代码段字节进行更改。接着用a3data数组的首四字节,同样需要用栈偏移确认索引,可以看成SMC过程中的密钥)初始化了一个临时数组s,使a1[i] ^= (i-50) ^ s[i%4],其中i-50为unsigned char类型。结合函数调用前的传参可得:
smc_decrypt(off_406940, qword_406948, data[0]);
off_406940存储原代码首地址,qword_406948存储原代码长度。在调用smc_decrypt前还会将其参数存在栈上([rbp-0D8h][rbp-0E0h][rbp-0CCh]),方便代码执行完成后的重新加密。

切片式SMC的例题解析及其自动化反混淆

代码重加密阶段

切片式SMC的例题解析及其自动化反混淆

该阶段的汇编需要在执行代码解密阶段恢复后才能看到,主要是smc_encrypt函数(0x403760),0x402488处的汇编为正常运行的原代码,可以忽略(在其余smc_encrypt函数前也有类似需忽略的正常代码)。可以看到在函数调用前直接把栈对应偏移的参数传入了寄存器中,继而被函数调用。该函数直接调用了smc_decrypt函数,由异或的性质也可知加密与解密可以完全相同:

切片式SMC的例题解析及其自动化反混淆

IDAPython 自动化反混淆

至此,切片式SMC的原理应该算是解析清楚了,由于程序中存在大量类似SMC,手动反混淆会比较繁琐,尤其是还需要手动维护局部变量数组。故这里使用IDAPython脚本来进行反混淆。
要达到全自动的目的,IDAPython脚本需要关注这几个关键点:函数调用的参数获取、SMC的密钥设置与解密、SMC代码的nop与函数重建
整个反混淆的思路是在指定的函数开头与结尾地址中进行遍历,函数开头搜索到调用_memset时初始化两个数组,并记录他们的栈偏移;搜索到调用smc_keyset时,往上寻找各参数并调用自己复刻的smc_keyset进行密钥设置;搜索到调用smc_decrypt时,同样往上寻找各参数并调用自己复刻的smc_decrypt进行代码解密;搜索到调用smc_encrypt时,往上寻找各参数并记录参数设置的地址。所有的相关指令都被记录在一个nop数组中,遍历结束以后nop掉这些指令,防止干扰对程序的分析,并重建函数方便反编译。
首先来写最简单的SMC的密钥设置与解密部分,用Python把smc_keysetsmc_decrypt两个函数复刻一下就可以了,注意常量需要从程序中获取,所以传进来的是处理后的地址,使用get_wide_byte和get_wide_dword进行获取:
def smc_keyset(state, size, idx_arr, data_arr, key):
    if state == 0:
        for i in range(size):
            idx = get_wide_dword(idx_arr + i*4)
            data_arr[idx] ^= key
    return

def smc_decrypt(code_addr, size, key):
    s = key.to_bytes(4'little')
    for i in range(size):
        code = get_wide_byte(code_addr+i)
        smc_key = ((i-50)&0xff) ^ s[i%4]
        patch_byte(code_addr+i, code ^ smc_key)
        del_items(code_addr+i)
    create_insn_with_check(code_addr)
    print("* Patch at", hex(code_addr), "for", size, "bytes with key", hex(key))
    return
patch_byte后的del_items是为了保证这些地址中的字节都是undefined对象,在后续create_insn时能正常创建指令。
create_insn_with_check是我自定义的函数,为的是能保证ea处的字节能被创建指令。
IDAPython的create_insn函数有点小坑,对于undefined的字节,只要这个字节开始反汇编出的指令覆盖到后面已经被定义(explored)的字节,那么就会create_insn失败。而在IDA界面中按快捷键C(MakeCode)对该字节创建指令,就会直接覆盖后面的explored字节。
所以就会出现按C能转指令但是create_insn转不了指令的情况。于是这个函数就是为了解决create_insn失败的问题:把后面的字节逐个无条件转成undefined,直到能成功创建ea处的指令。(其实为了鲁棒性还应该检查最后return的时候off==create_insn(ea)的,但没出bug就不管了)
def create_insn_with_check(ea):
    off = 1
    while create_insn(ea) == 0:
        del_items(ea, 0, off)
        off += 1
    return
遍历部分就是一个纯粹的顺序遍历反汇编,主要是对汇编指令的特征进行匹配,匹配到对应的关键词进行对应操作。这需要分别对三个smc函数更名以后才会正常匹配,不然脚本啥都匹配不到。
smc_keysetsmc_decrypt,向前遍历并使用正则匹配指令的操作数,获取到参数后调用对应的函数;对初始化阶段的两个memset,由于一定会在函数的最开头(不然后面解密就没有局部变量可用了),那么匹配到以后就不会再匹配了,并且记录他们创建的局部变量数组的偏移,在smc_keysetsmc_decrypt两个函数调用时需要用到这个偏移计算参数;对smc_encrypt,同样需要向上寻找参数,但只是用于寻找这些添加参数的指令。这些涉及到的指令都会添加进nop的列表g_nop_addrs里,防止赋值干扰后续分析(需要保证后续使用到这些寄存器时会重新赋值)。
遍历时使用ea += get_item_size(ea)而不是next_head()是因为对于后续不是指令的地址(因为后面的是没反混淆的所以必然会出现这种情况),next_head()会直接跳过,从而影响程序的顺序扫描。
def dobf_func(func_addr_t):
    arr_addrs = [] # [state_addr, state_size], [data_addr, data_size]
    ea = func_addr_t[0]
    print("START at", hex(ea))
    while ea < func_addr_t[1]:
        try:
            create_insn_with_check(ea)
        except:
            print("FUNC END at", hex(ea))
            break
        opcode = print_insn_mnem(ea)
        operand = print_operand(ea, 0)
        if operand == "smc_keyset":
            assert len(arr_addrs) == 2
            addr = ea
            args = []
            for i in range(5)[::-1]:
                addr = prev_head(addr)
                opn = print_operand(addr, 1)
                matchobj = re.match(SET_PTN[i], opn)
                if matchobj is None:
                    matchobj = re.match(r'([0-9A-F]{1,9})h?', opn)
                assert matchobj is not None
                args.append(int(matchobj.groups()[0], 16))
            g_nop_addrs.append((addr, ea+get_item_size(ea), NOP_TYPE.index("KEY")))
            args = args[::-1]
            args[0] = state_arr[arr_addrs[0][0] - args[0]]
            args[3] = data_arr
            print("* smc_keyset addr:", hex(ea))
            smc_keyset(*args)
        elif operand == "smc_decrypt":
            assert len(arr_addrs) == 2
            addr = ea
            args = []
            for i in range(10):
                addr = prev_head(addr)
                op = print_insn_mnem(addr)
                opn0 = print_operand(addr, 0)
                opn1 = print_operand(addr, 1)
                if op == "mov" and opn1 == "1":
                    matchobj = re.match(SET_PTN[0], opn0)
                    state_addr = int(matchobj.groups()[0], 16)
                    assert matchobj is not None
                    state_arr[(arr_addrs[0][0]-state_addr)] = 1
                    break
            g_nop_addrs.append((addr, ea+get_item_size(ea), NOP_TYPE.index("DEC")))
            for i in range(3):
                addr = next_head(addr)
                opn = print_operand(addr, 1)
                matchobj = re.match(DEC_PTN[i], opn)
                assert matchobj is not None
                args.append(int(matchobj.groups()[0], 16))
            assert (arr_addrs[1][0] - args[2]) % 4 == 0
            args[0] = get_qword(args[0])
            args[1] = get_qword(args[1])
            args[2] = data_arr[(arr_addrs[1][0]-args[2])//4]
            print("* smc_decrypt addr:", hex(ea))
            smc_decrypt(*args)
        elif operand == "smc_encrypt":
            search_up_for_args(ea, encrypt_searchDict, NOP_TYPE.index("ENC"))
            g_nop_addrs.append((ea, ea+get_item_size(ea), NOP_TYPE.index("ENC")))
        elif len(arr_addrs) != 2 and operand == "_memset":
            addr = ea
            g_nop_addrs.append((ea, ea+get_item_size(ea), NOP_TYPE.index("MEMSET")))
            for i in range(5): # two _memset funcs apart <= 5 bytes
                addr += get_item_size(addr)
                create_insn_with_check(addr)
                if print_operand(addr, 0) == "_memset":
                    g_nop_addrs.append((addr, addr+get_item_size(addr), NOP_TYPE.index("MEMSET")))
                    for x in [ea, addr]:
                        memset_args = search_up_for_args(x, memset_searchDict, NOP_TYPE.index("MEMSET"))
                        memset_args = memset_args[:1] + memset_args[2:]
                        arr_addrs.append([t[0for t in memset_args])
                    state_arr = [0] * arr_addrs[0][1]
                    data_arr = [0] * arr_addrs[1][1]
                    break
            print("INFO: init ", arr_addrs)
        ea += get_item_size(ea)
其中search_up_for_args也是自己实现的函数,用于向上寻找参数,当遇到形如lea rcx, [rbp-0C4h]、 mov rdi, rcx的指令组合时也能正常获取,在memsetsmc_encrypt中会出现(smc_keysetsmc_decrypt是没有这种组合指令的,所以直接向前遍历提高效率,用assert上一层保险)。
def search_up_for_args(ea, searchDict, nop):
    global g_nop_addrs
    tl = []
    sDict = searchDict.copy()
    while bool(sDict):
        ea = prev_head(ea)
        opn0 = print_operand(ea, 0)[-2:]
        opn1 = print_operand(ea, 1)
        if opn0 in sDict.keys():
            g_nop_addrs.append((ea, ea+get_item_size(ea), nop))
            matchobj = re.match(sDict[opn0][0], opn1)
            if matchobj is not None:
                try:
                    tl.append((int(matchobj.groups()[0], 16), sDict[opn0][1]))
                except ValueError:
                    tl.append(("NONE", sDict[opn0][1]))
            else:
                sDict.update({opn1[-2:]: sDict[opn0]})
            del sDict[opn0]
    tl.sort(key=lambda t:t[1])
    return tl
最后就是主函数,对指定的函数地址进行反混淆(需要手动指定函数尾,ida还是勉强能看出来的),然后对g_nop_addrs里的地址nop并隐藏(便于后续的反编译和反汇编分析),最后重新遍历一次把前面没创指令对象的全部创建,并把ida之前错误分析时生成的函数删除,最后重建函数。
def main():
    for i in range(len(g_func_addrs)):
        dobf_func(g_func_addrs[i])
    for t in g_nop_addrs:
        for i in range(t[0], t[1]):
            patch_byte(i, 0x90)
            create_insn(i)
        add_hidden_range(t[0], t[1], NOP_TYPE[t[2]], ''''0xFFFFFF)
    for t in g_func_addrs:
        ea = t[0]
        while ea < t[1]:
            create_insn(ea)
            del_func(ea)
            ea += get_item_size(ea)
        add_func(t[0], t[1])
完整的IDAPython反混淆脚本在https://github.com/c10udlnk/AutoDeobfSliceSMC/blob/main/IDAPython.py。

babyobfu解题过程

反混淆后会看到这种jumpout的字样,是因为nop太多了(IDA不会太长的nop slide),在空白处多点点(或者上下滑动都行)它就能慢慢识别到了。

切片式SMC的例题解析及其自动化反混淆

首先对输入进行了检查,长度为32且在[0-9a-f]内。
然后进了一个sub_401980(in, out, len)的函数,可以通过调用猜测三个参数分别为输入、输出的位置和输入长度,一些局部变量可以通过汇编上下跟踪进行确认,并改为方便辨认的名字以助分析。
函数内第一个循环实际上做的工作是初始化了一个数组arr1,并将它的每一个数据累加到sum1,用Python表达有:
arr1 = [0 for _ in range(32)]
sum1 = 0
for i in range(32):
    arr1[i] = (get_secret()%3+1) & 0xFF
    sum1 += arr1[i]
    sum1 &= 0xFFFFFFFF
其中get_secret函数(0x4011B0)通过简单计算更改了四个全局变量,并返回一个相乘的累加值,用Python表达有:
g_secret = [0xDEADBEEF0xAAC32EF50x3548AC2D0xCACDEF2D# 初始值
def get_secret():
    global g_secret
    for i in range(8):
        g_secret[0] += ((i+1)*g_secret[3]) & g_secret[1] ^ (289739946*g_secret[2]+g_secret[1]) ^ (16*g_secret[0])
        g_secret[0] &= 0xFFFFFFFF # 32bits
        g_secret[2] -= ((345*g_secret[3]+123*g_secret[1]+567*g_secret[2]) | (8*g_secret[3])) ^ (1146171994*g_secret[0]) ^ (i*g_secret[1])
        g_secret[2] &= 0xFFFFFFFF
        g_secret[1] -= (0xCCCCCCCC*g_secret[0]+7*g_secret[1]) | ((g_secret[2]>>(32-4*i))|(g_secret[2]<<(4*i))) ^ (g_secret[0]*g_secret[2])
        g_secret[1] &= 0xFFFFFFFF
        g_secret[3] += ((19076178*i)+g_secret[0]) * ((2026744383*g_secret[3]) ^ (16*g_secret[2]) ^ (g_secret[1]>>3))
        g_secret[3] &= 0xFFFFFFFF
    return (g_secret[3]*g_secret[2] + g_secret[2]*g_secret[1] + g_secret[1]*g_secret[0] + g_secret[0]*g_secret[3])&0xFFFFFFFF
第二个循环初始化了第二个数组arr2,长度由sum1决定(但是循环长度却是sum1+3,真的不会溢出吗...看了汇编也是如此)

切片式SMC的例题解析及其自动化反混淆

用Python表示有:
charset = "1234567890abcdef"
arr2 = [0 for _ in range(sum1+3)] 
for i in range(sum1+3):
    arr2[i] = charset[get_secret()&0xF]
第三个循环用来把前面的数据存进输出数组中,用Python重写:
sum2 = 0
for i in range(32):
    sum2 += arr1[i]
    out[2*i] = arr2[sum2]
    out[2*i+1] = charset[(arr2[sum2]+3*a2n(in[i])) % 16]
其中a2n函数(0x401580)就是一个转换,将ascii码转成对应的数字(比如'0' -> 0、'a' -> 10)。

切片式SMC的例题解析及其自动化反混淆

出来以后的循环就是一个unhex操作,由上可知sub_401980的输出均是在程序的自定义charset("1234567890abcdef")中的,这里就是把这些字符两两一组unhex成字节("ab" -> 0xab)。

切片式SMC的例题解析及其自动化反混淆

最后一个三层循环的处理如下:

切片式SMC的例题解析及其自动化反混淆

用Python重写:
for i in range(4):
    for j in range(100): # 动态调试跳不出来的原因
        for k in range(8)[::-1]:
            v12 = nums[(get_secret()+dst[i*8+k])&0xFF]
            v13 = ((dst[i*8+(k+1)%8]<<7) | (dst[i*8+(k+1)%8]>>1)) & 0xFF
            dst[i*8+(k+1)%8] = (v13 - v12) & 0xFF
nums是已知数组(实际上是AES的S盒,但跟AES没关系),dst就是最后要跟已知数组比对的数组。
所以加密流程汇总是:
arr1 = [0 for _ in range(32)]
sum1 = 0
for i in range(32):
    arr1[i] = (get_secret()%3+1) & 0xFF
    sum1 += arr1[i]
    sum1 &= 0xFFFFFFFF
charset = "1234567890abcdef"
arr2 = [0 for _ in range(sum1+3)] 
for i in range(sum1+3):
    arr2[i] = charset[get_secret()&0xF]
sum2 = 0
for i in range(32):
    sum2 += arr1[i]
    out[2*i] = arr2[sum2]
    out[2*i+1] = charset[(ord(arr2[sum2])+3*a2n(in[i])) % 16]
dst = list(bytes.fromhex(out))
for i in range(4):
    for j in range(100): # 动态调试跳不出来的原因
        for k in range(8)[::-1]:
            v12 = nums[(get_secret()+dst[i*8+k])&0xFF]
            v13 = ((dst[i*8+(k+1)%8]<<7) | (dst[i*8+(k+1)%8]>>1)) & 0xFF
            dst[i*8+(k+1)%8] = (v13 - v12) & 0xFF
于是逆过来写exp就行,注意一下get_secret()的顺序,而且原文中arr2[sum2]其实不是必须需要得到的数,完全可以从out[2*i]中直接取,信息冗余。最后(arr2[sum2]+3*a2n(in[i])) % 16的地方直接爆破即可。
g_secret = [0xDEADBEEF0xAAC32EF50x3548AC2D0xCACDEF2D]
def get_secret():
    global g_secret
    for i in range(8):
        g_secret[0] += ((i+1)*g_secret[3]) & g_secret[1] ^ (289739946*g_secret[2]+g_secret[1]) ^ (16*g_secret[0])
        g_secret[0] &= 0xFFFFFFFF # 32bits
        g_secret[2] -= ((345*g_secret[3]+123*g_secret[1]+567*g_secret[2]) | (8*g_secret[3])) ^ (1146171994*g_secret[0]) ^ (i*g_secret[1])
        g_secret[2] &= 0xFFFFFFFF
        g_secret[1] -= (0xCCCCCCCC*g_secret[0]+7*g_secret[1]) | ((g_secret[2]>>(32-4*i))|(g_secret[2]<<(4*i))) ^ (g_secret[0]*g_secret[2])
        g_secret[1] &= 0xFFFFFFFF
        g_secret[3] += ((19076178*i)+g_secret[0]) * ((2026744383*g_secret[3]) ^ (16*g_secret[2]) ^ (g_secret[1]>>3))
        g_secret[3] &= 0xFFFFFFFF
    return (g_secret[3]*g_secret[2] + g_secret[2]*g_secret[1] + g_secret[1]*g_secret[0] + g_secret[0]*g_secret[3])&0xFFFFFFFF

dst = b''
dst += 0x376856ABEED8592A.to_bytes(8'little')
dst += 0x3CCF537F7ECA40AB.to_bytes(8'little')
dst += 0x92CC25F6240A7A19.to_bytes(8'little')
dst += 0x2DA210592DCCFF78.to_bytes(8'little')
dst = list(dst)
nums = [0x630x7C0x770x7B0xF20x6B0x6F0xC50x300x010x670x2B0xFE0xD70xAB0x760xCA0x820xC90x7D0xFA0x590x470xF00xAD0xD40xA20xAF0x9C0xA40x720xC00xB70xFD0x930x260x360x3F0xF70xCC0x340xA50xE50xF10x710xD80x310x150x040xC70x230xC30x180x960x050x9A0x070x120x800xE20xEB0x270xB20x750x090x830x2C0x1A0x1B0x6E0x5A0xA00x520x3B0xD60xB30x290xE30x2F0x840x530xD10x000xED0x200xFC0xB10x5B0x6A0xCB0xBE0x390x4A0x4C0x580xCF0xD00xEF0xAA0xFB0x430x4D0x330x850x450xF90x020x7F0x500x3C0x9F0xA80x510xA30x400x8F0x920x9D0x380xF50xBC0xB60xDA0x210x100xFF0xF30xD20xCD0x0C0x130xEC0x5F0x970x440x170xC40xA70x7E0x3D0x640x5D0x190x730x600x810x4F0xDC0x220x2A0x900x880x460xEE0xB80x140xDE0x5E0x0B0xDB0xE00x320x3A0x0A0x490x060x240x5C0xC20xD30xAC0x620x910x950xE40x790xE70xC80x370x6D0x8D0xD50x4E0xA90x6C0x560xF40xEA0x650x7A0xAE0x080xBA0x780x250x2E0x1C0xA60xB40xC60xE80xDD0x740x1F0x4B0xBD0x8B0x8A0x700x3E0xB50x660x480x030xF60x0E0x610x350x570xB90x860xC10x1D0x9E0xE10xF80x980x110x690xD90x8E0x940x9B0x1E0x870xE90xCE0x550x280xDF0x8C0xA10x890x0D0xBF0xE60x420x680x410x990x2D0x0F0xB00x540xBB0x16]
charset = "1234567890abcdef"
arr1 = [0 for _ in range(32)]
sum1 = 0
sum2 = 0
for i in range(32): # 为了拿sum1和跑get_secret
    arr1[i] = (get_secret()%3+1) & 0xFF
    sum1 += arr1[i]
    sum1 &= 0xFFFFFFFF
arr2 = [0 for _ in range(sum1+3)] 
for i in range(sum1+3): # 只是为了跑get_secret
    arr2[i] = charset[get_secret()&0xF]

# 三层循环的逆向
keys = [get_secret() for _ in range(4*100*8)]
for i in range(4):
    tmpkeys = keys[i*100*8:(i+1)*100*8][::-1]
    for j in range(100):
        for k in range(8):
            y = nums[(dst[i*8+k]+tmpkeys[j*8+k])&0xFF]
            x = (dst[i*8+(k+1)%8]+y) & 0xFF
            x = (x<<1)&0xFF | (x>>7)
            dst[i*8+(k+1)%8] = x
dst = bytes(dst).hex()

# sub_401980的逆向
flag = []
for i in range(32):
    # 爆破
    for x in range(16):
        a = ord(dst[2*i]) # out[2*i] = arr2[sum2]
        b = charset.index(dst[2*i+1])
        if (a+x*3)%16 == b: # out[2*i+1] = charset[(ord(arr2[sum2])+3*a2n(in[i])) % 16]
            flag.append(x)
            break
flag = ''.join([hex(x)[-1for x in flag])
print("DASCTF{" + flag + "}")
# DASCTF{9d9b9ff1c62122ba7f5b54385b1f9d64}

参考

1. https://www.hex-rays.com/products/ida/support/idadoc/
2. https://www.hex-rays.com/products/ida/support/idapython_docs/


原文始发于微信公众号(山石网科安全技术研究院):切片式SMC的例题解析及其自动化反混淆

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2023年9月21日12:47:57
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   切片式SMC的例题解析及其自动化反混淆https://cn-sec.com/archives/2055110.html

发表评论

匿名网友 填写信息