-
-
[原创] 第四题西西弗斯的滚石未完成的wp
-
发表于: 2020-4-23 12:49 5178
-
我这么喜欢写前言不仅仅是为了凑字数
本来一直打算做防守方的, 因为当攻击方的话会暴露我题啥都做不出来的事实. 每次比赛都只做个签到题凑凑人数. 但是这一题到第三天早上攻破人数还是 0, 我作为防守方压力很大啊, 强烈怀疑 ccfer, 风神, 以及 riatre 这几天疯狂划水没认真玩. 这一题刚出来的时候可能大爷在打 Plaid CTF, 然后打完 Plaid CTF 之后大爷可能觉得自己没有 ak 有点不开心就没来做看雪 CTF 了. 希望大爷做我那题的时候也手下留情 (强烈暗示想 py 交易.
总之, 各大高手都没出马, 我作为无名小卒只能上上滥竽充数了. 昨天做了一天, 等到昨晚被我妈强行拉出门散步的时候我就知道我这题肯定是做不完的了. 今天早上起来打开电脑确认了一下这个事实, 也就干脆放弃了. 不过虽然没来得及搞完, 我大概把 smc 的部分给去掉了. 想想没人做出来的题可能也没人写 writeup, 题目下面空荡荡的有点不好看, 我就献丑一下, 简单说一下我的思路和进度. 感觉如果再有一天是应该是可以做出来的, 再次谴责大爷不出手, 没给我膜的机会.
(散个步都能散出来 SSA)
这个已经不是前言了但是废话依然很多
先说一下目前的进展, 就是大概把程序每一个大循环的验证逻辑抽出来编译成了一个新的 binary, 可能比源程序好看一点, 我上传个附件扔到下面. 拖进 IDA 里面大概长这样:
这个函数已经没有分支啥的了, 但是还有不少混淆没去掉, 可以隐隐约约看出来有个 Feistel 的结构. 反编译出来有 2k 行, 不太是人写出来的, 看上去有明显的规律性, 应该还是程序生成的. 至于怎么得到这个验证逻辑的就看下面详细一点的废话吧.
大标题用得久了人就会情不自禁的想换成小标题
首先打开 IDA, 简单看了下逻辑, 发现大概是一个关键函数把序列号变换成用户名, 点击去一看发现一大堆乱七八糟的长得不像代码没被标记成代码也不太能被标记成代码的玩意. 中国有一句古话, (叫做闷声大发...), 是如果一个玩意长得不像鸭子, 叫的不像鸭子, 走的也不像鸭子, 那它就不是鸭子. 好, 果断(删掉)关掉 IDA.
然后打开 x64dbg, 进入函数调试了一下, 首先第一个反应是这个函数真他喵的慢. 直接运行了一下确认了不是调试器的问题, (那应该就是我电脑太便宜了的问题需要换电脑了). 简单跟了一下 smc, 只觉人力有时尽, ret 无可期. 试着把 rsp 设到了一个页的边缘, 果然找到了 ret, 但是周围白茫茫一片全是乱码. 感觉这样下去迟早会(精尽人亡)精疲力竭, 于是放弃了手工调试.
不过调试了一会也有所得, 就是这个函数输入是 rcx, rdx, 输出也是 rcx, rdx, 似乎只用到了栈空间. 在开头某个位置打断点, 发现会被触发很多次, 仔细观察发现只有 rcx 和 rdx 改变了, 连栈都被填充成 0x12345678. 但是程序是没有死循环的, 那么肯定有条件使它跳出循环, 而这个条件多半和(神奇的海螺) rcx rdx 无关. 仔细观察发现每一次循环的时候代码都会有少许的变动, 把代码区域 dump 下来 diff 了一下发现变动还不少, 但是都是 smc 解密后对应到指令常数的改变. 至此这个程序大概就有个(没什么卵用的)印象了.
(有一说一加括号还真的挺好玩的我已经爱上它了)
作为一个只会用工具的菜鸡, 常用工具用完了就来试试不常用的工具. 首先我做了几点假设, 这个函数的行为多半只是一个数据变换, 和 block cipher 行为类似, 就是说不同输入所对应的路径应该是一样的(虽然后来发现不完全一样). 然后就是这个函数多半比较老实, 没有出现调用其他函数或者 syscall 的调皮行为, 再次就是假设这个函数只用到自己的代码和栈的一小部分的内存(后面这两个假设都得到了验证). 最后就是假设该函数的每个大循环都大同小异了. 对于这种冰冷的数据变换机器, 有一个叫 Triton 的 DBI 框架其实还挺适合的.
Triton 的介绍就懒得介绍了, 文档勉强还算丰富, 示例倒是挺多的. 下下来编译安装, 把程序 load 进去, 发现一个循环要跑 20 来分钟... 不过还算可以接受, 毕竟完全可以搞个 64 核的服务器并行跑不同轮的循环.
跑完之后, Triton 会包含一个输出到输入的 AST 结构, 我们把这个结构稍稍处理一下就能转换成 LLVM IR, 然后就能依赖我们强大的编译器的优化来帮我们去掉混淆了. 但是在处理的过程中, 发现 Triton 对于简单的不透明谓词的处理不是很好, 比如 if(1 == 0) {balabala(a)} else {1}
这种, 其实应该是返回一个常量, 但是 triton 会认为它是一个 symbolic expression. 看了看它的代码后, 发现只要简单改改 src/libtriton/ast/ast.cpp
就可以了:
for (triton::uint32 index = 0; index < this->children.size(); index++) { this->children[index]->setParent(this); this->symbolized |= this->children[index]->isSymbolized(); this->level = std::max(this->children[index]->getLevel() + 1, this->level); }
它判断 symbolized 的时候只要有一个 child 是 symbolized 就行, 这个太笨了. 把它改成:
for (triton::uint32 index = 0; index < this->children.size(); index++) { this->children[index]->setParent(this); //this->symbolized |= this->children[index]->isSymbolized(); this->level = std::max(this->children[index]->getLevel() + 1, this->level); } if(this->children[0]->isSymbolized()){ this->symbolized = true; }else{ this->symbolized = this->children[0]->evaluate() ? this->children[1]->isSymbolized() : this->children[2]->isSymbolized(); }
除了这种不透明谓词导致的分支, 还有一种分支来源于 a ^ 0x100 = if(a & 0x100) {a-0x100} else {a|0x100}
这一类的混淆, 于是在结算分支的时候顺便也把它干掉了.
然后发现 arybo 对 mod 支持不行, 但是里面所有的 mod 全是 mod 0x40, 所以就干脆当成 & 0x3F 来处理了.
其实还发现好多问题, 但是都因为这个程序一些 specific 的因素慢慢排除掉了. 所以下面的代码直接拿过来用到其他程序上肯定是行不通的.
import sys,resource sys.setrecursionlimit(50000) resource.setrlimit(resource.RLIMIT_STACK, (resource.RLIM_INFINITY, resource.RLIM_INFINITY)) from triton import * from arybo.lib.mba_exprs import ExprCond import ctypes import os import random import string import struct import time import lief ctx = TritonContext() ctx.setArchitecture(ARCH.X86_64) ctx.setMode(MODE.ALIGNED_MEMORY, True) ctx.setMode(MODE.ONLY_ON_SYMBOLIZED, True) ctx.setAstRepresentationMode(AST_REPRESENTATION.PYTHON) func2 = open("func2", "rb").read() stack2 = open("stack2", "rb").read() reg2=[610839792, 916259688, 2147483647, 2147483647, 1221679584, 1527099480, 1832519376, 2137939272, 2443359168, 2748779064, 3054198960, 3359618856, 3970458648, 3970458648, 5368808131] regs=[ctx.registers.rax, ctx.registers.rbx, ctx.registers.rbp, ctx.registers.rsp, ctx.registers.rsi, ctx.registers.rdi, ctx.registers.r8, ctx.registers.r9, ctx.registers.r10, ctx.registers.r11, ctx.registers.r12, ctx.registers.r13, ctx.registers.r14, ctx.registers.r15, ctx.registers.rip] ctx.setConcreteMemoryAreaValue(0x1400011E0, func2) ctx.setConcreteMemoryAreaValue(0x7fffffff-len(stack2), stack2) for i in range(len(regs)): ctx.setConcreteRegisterValue(regs[i], reg2[i]) ctx.concretizeAllRegister() ctx.concretizeAllMemory() ctx.setConcreteRegisterValue(ctx.registers.rcx, 0x68A046A2B0886BFE) ctx.setConcreteRegisterValue(ctx.registers.rdx, 0x92D4E06F09775269) in0=ctx.symbolizeRegister(ctx.registers.rcx) in1=ctx.symbolizeRegister(ctx.registers.rdx) pc = ctx.getConcreteRegisterValue(ctx.registers.rip) while pc: opcodes = ctx.getConcreteMemoryAreaValue(pc, 16) instruction = Instruction() instruction.setOpcode(opcodes) instruction.setAddress(pc) if ctx.processing(instruction) == False: debug('[-] Instruction not supported: %s' %(str(instruction))) break if instruction.getType() == OPCODE.X86.RET: print("return") break pc = ctx.getConcreteRegisterValue(ctx.registers.rip) if pc == 0x0000001400182C3: print("Loop") break out0=ctx.getSymbolicRegister(ctx.registers.rcx) out1=ctx.getSymbolicRegister(ctx.registers.rdx) from arybo.lib import MBA, MBAVariable, flatten import arybo.lib.mba_exprs as EX import operator import six from six.moves import reduce mba=MBA(64) def _get_mba(n,use_esf): mba = MBA(n) mba.use_esf = use_esf return mba def my_tritonast2arybo(e, use_exprs=True, use_esf=False, context=None): ''' Convert a subset of Triton's AST into Arybo's representation Args: e: Triton AST use_esf: use ESFs when creating the final expression context: dictionnary that associates Triton expression ID to arybo expressions Returns: An :class:`arybo.lib.MBAVariable` object ''' children_ = e.getChildren() children = (my_tritonast2arybo(c,use_exprs,use_esf,context) for c in children_) reversed_children = (my_tritonast2arybo(c,use_exprs,use_esf,context) for c in reversed(children_)) Ty = e.getType() if Ty == AST_NODE.ZX: n = next(children) v = next(children) n += v.nbits if n == v.nbits: return v return v.zext(n) if Ty == AST_NODE.SX: n = next(children) v = next(children) n += v.nbits if n == v.nbits: return v return v.sext(n) if Ty == AST_NODE.INTEGER: return e.getInteger() if Ty == AST_NODE.BV: cst = next(children) nbits = next(children) if use_exprs: return EX.ExprCst(cst, nbits) else: return _get_mba(nbits,use_esf).from_cst(cst) if Ty == AST_NODE.EXTRACT: last = next(children) first = next(children) v = next(children) return v[first:last+1] if Ty == AST_NODE.CONCAT: if use_exprs: return EX.ExprConcat(*list(reversed_children)) else: return flatten(reversed_children) if Ty == AST_NODE.VARIABLE: name = e.getSymbolicVariable().getName() ret = _get_mba(e.getBitvectorSize(),use_esf).var(name) if use_exprs: ret = EX.ExprBV(ret) return ret if Ty == AST_NODE.REFERENCE: if context is None: raise ValueError("reference node without context can't be resolved") id_ = e.getSymbolicExpression().getId() ret = context.get(id_, None) if ret is None: print("Evaluate: ", e.getSymbolicExpression()) return EX.ExprCst(e.evaluate(), 64) #raise ValueError("expression id %d not found in context" % id_) return ret if Ty == AST_NODE.LET: # Alias # djo: "c'est pas utilise osef" raise ValueError("unsupported LET operation") # Logical/arithmetic shifts shifts = { AST_NODE.BVASHR: lambda a,b: a.ashr(b), AST_NODE.BVLSHR: lambda a,b: a.lshr(b), AST_NODE.BVSHL: operator.lshift, AST_NODE.BVROL: lambda x,n: x.rol(n), AST_NODE.BVROR: lambda x,n: x.ror(n) } shift = shifts.get(Ty, None) if not shift is None: v = next(children) n = next(children) return shift(v,n) # Unary op unops = { AST_NODE.BVNOT: lambda x: ~x, AST_NODE.LNOT: lambda x: ~x, AST_NODE.BVNEG: operator.neg } unop = unops.get(Ty, None) if not unop is None: return unop(next(children)) binops = { AST_NODE.BVADD: operator.add, AST_NODE.BVSUB: operator.sub, AST_NODE.BVAND: operator.and_, AST_NODE.BVOR: operator.or_, AST_NODE.BVXOR: operator.xor, AST_NODE.BVMUL: operator.mul, AST_NODE.BVNAND: lambda x,y: ~(x&y), AST_NODE.BVNOR: lambda x,y: ~(x|y), AST_NODE.BVXNOR: lambda x,y: ~(x^y), AST_NODE.BVUDIV: lambda x,y: x.udiv(y), AST_NODE.BVSDIV: lambda x,y: x.sdiv(y), AST_NODE.BVUREM: lambda x,y: x.urem(y), AST_NODE.BVSREM: lambda x,y: x.srem(y), AST_NODE.BVSMOD: lambda x,y: x & 0x3F, # Temp sol but it works AST_NODE.LAND: operator.and_, AST_NODE.LOR: operator.or_ } binop = binops.get(Ty, None) if not binop is None: if Ty == AST_NODE.BVSMOD: assert e.getChildren()[1].getChildren()[0].getInteger() == 0x40 return reduce(binop, children) # Logical op lops = { AST_NODE.EQUAL: lambda x,y: EX.ExprCmpEq(x,y), AST_NODE.DISTINCT: lambda x,y: EX.ExprCmpNeq(x,y), AST_NODE.BVUGE: lambda x,y: EX.ExprCmpGte(x,y,False), AST_NODE.BVUGT: lambda x,y: EX.ExprCmpGt(x,y,False), AST_NODE.BVULE: lambda x,y: EX.ExprCmpLte(x,y,False), AST_NODE.BVULT: lambda x,y: EX.ExprCmpLt(x,y,False), AST_NODE.BVSGE: lambda x,y: EX.ExprCmpGte(x,y,True), AST_NODE.BVSGT: lambda x,y: EX.ExprCmpGt(x,y,True), AST_NODE.BVSLE: lambda x,y: EX.ExprCmpLte(x,y,True), AST_NODE.BVSLT: lambda x,y: EX.ExprCmpLt(x,y,True) } lop = lops.get(Ty, None) if not lop is None: return reduce(lop, children) # Conditional if Ty != AST_NODE.ITE: raise ValueError("unsupported node type %s" % str(Ty)) cond = next(children) ifc = next(children) elsec = next(children) if not e.getChildren()[0].isSymbolized(): print(e.getParents()) if e.getChildren()[0].evaluate(): return ifc else: return elsec #print(e.getChildren()[1].getChildren()[1]) #return EX.ExprCond(cond, ifc, elsec) return my_tritonast2arybo(e.getChildren()[1].getChildren()[0],use_exprs,use_esf,context) ^ my_tritonast2arybo(e.getChildren()[1].getChildren()[1],use_exprs,use_esf,context) def my_tritonexprs2arybo(exprs): context = {} e = None for id_,e in sorted(exprs.items()): if id_ in context: raise ValueError("expression id %d is set multiple times!" % id_) e = my_tritonast2arybo(e.getAst(), True, False, context) context[id_] = e return e, context astctx=ctx.getAstContext() dest0=astctx.bv(0xA11F5C1A02B84180, 64) dest1=astctx.bv(0xB42247957CDD5A66, 64) eq0=astctx.equal(dest0, out0.getAst()) eq1=astctx.equal(dest1, out1.getAst()) flag=astctx.bvand(eq0,eq1) sep=ctx.getSymbolicExpressions() e,symcontext=my_tritonexprs2arybo(sep) my_tritonast2arybo(eq0, True, False, symcontext) my_tritonast2arybo(eq1, True, False, symcontext) e=my_tritonast2arybo(flag, True, False, symcontext) in0=tritonast2arybo(ctx.getAstContext().variable(ctx.getSymbolicVariable(0))) in1=tritonast2arybo(ctx.getAstContext().variable(ctx.getSymbolicVariable(1))) debug_temp = None try: import llvmlite.ir as ll import llvmlite.binding as llvm import ctypes llvmlite_available = True __llvm_initialized = False except ImportError: llvmlite_available = False import six import collections import hashlib import arybo.lib.mba_exprs as EX from arybo.lib.exprs_passes import lower_rol_ror, CachePass def IntType(n): return ll.IntType(int(n)) class ToLLVMIr(CachePass): def __init__(self, sym_to_value, IRB): super(ToLLVMIr,self).__init__() self.IRB = IRB self.sym_to_value = sym_to_value self.values = {} def visit_wrapper(self, e, cb): ret = super(ToLLVMIr, self).visit_wrapper(e, cb) if not isinstance(ret, tuple): return (ret,self.IRB.block) else: return ret def visit_value(self, e): return EX.visit(e, self)[0] def visit_Cst(self, e): return ll.Constant(IntType(e.nbits), e.n) def visit_BV(self, e): name = e.v.name value = self.sym_to_value.get(name, None) if value is None: raise ValueError("unable to map BV name '%s' to an LLVM value!" % name) # TODO: check value bit-size #ret,nbits = value #if e.nbits != nbits: # raise ValueError("bit-vector is %d bits, expected %d bits" % (e.nbits, nbits)) return value def visit_Not(self, e): return self.IRB.not_(self.visit_value(e.arg)) def visit_ZX(self, e): return self.IRB.zext(self.visit_value(e.arg), IntType(e.n)) def visit_SX(self, e): return self.IRB.sext(self.visit_value(e.arg), IntType(e.n)) def visit_Concat(self, e): # Generate a suite of OR + shifts # TODO: pass that lowers concat arg0 = e.args[0] ret = self.visit_value(arg0) type_ = IntType(e.nbits) ret = self.IRB.zext(ret, type_) cur_bits = arg0.nbits for a in e.args[1:]: cur_arg = self.IRB.zext(self.visit_value(a), type_) ret = self.IRB.or_(ret, self.IRB.shl(cur_arg, ll.Constant(type_, cur_bits))) cur_bits += a.nbits return ret def visit_Slice(self, e): # TODO: pass that lowers slice ret = self.visit_value(e.arg) idxes = e.idxes # Support only sorted indxes for now if idxes != list(range(idxes[0], idxes[-1]+1)): raise ValueError("slice indexes must be continuous and sorted") if idxes[0] != 0: ret = self.IRB.lshr(ret, ll.Constant(IntType(e.arg.nbits), idxes[0])) return self.IRB.trunc(ret, IntType(len(idxes))) def visit_Broadcast(self, e): # TODO: pass that lowers broadcast # left-shift to get the idx as the MSB, and them use an arithmetic # right shift of nbits-1 type_ = IntType(e.nbits) ret = self.visit_value(e.arg) ret = self.IRB.zext(ret, type_) ret = self.IRB.shl(ret, ll.Constant(type_, e.nbits-e.idx-1)) return self.IRB.ashr(ret, ll.Constant(type_, e.nbits-1)) def visit_nary_args(self, e, op): return op(*(self.visit_value(a) for a in e.args)) def visit_BinaryOp(self, e): ops = { EX.ExprAdd: self.IRB.add, EX.ExprSub: self.IRB.sub, EX.ExprMul: self.IRB.mul, EX.ExprShl: self.IRB.shl, EX.ExprLShr: self.IRB.lshr, EX.ExprAShr: self.IRB.ashr } op = ops[type(e)] return self.visit_nary_args(e, op) def visit_Div(self, e): return self.visit_nary_args(e, self.IRB.sdiv if e.is_signed else self.IRB.udiv) def visit_Rem(self, e): return self.visit_nary_args(e, self.IRB.srem if e.is_signed else self.IRB.urem) def visit_NaryOp(self, e): ops = { EX.ExprXor: self.IRB.xor, EX.ExprAnd: self.IRB.and_, EX.ExprOr: self.IRB.or_, } op = ops[type(e)] return self.visit_nary_args(e, op) def visit_Cmp(self, e): f = self.IRB.icmp_signed if e.is_signed else self.IRB.icmp_unsigned cmp_op = { EX.ExprCmp.OpEq: '==', EX.ExprCmp.OpNeq: '!=', EX.ExprCmp.OpLt: '<', EX.ExprCmp.OpLte: '<=', EX.ExprCmp.OpGt: '>', EX.ExprCmp.OpGte: '>=' } return f(cmp_op[e.op], self.visit_value(e.X), self.visit_value(e.Y)) def visit_Cond(self, e): global debug_temp cond = self.visit_value(e.cond) selfbb = self.IRB.basic_block bb_name = selfbb.name ifbb = self.IRB.append_basic_block(hashlib.sha256((bb_name + ".if").encode("utf-8")).hexdigest()) elsebb = self.IRB.append_basic_block(hashlib.sha256((bb_name + ".else").encode("utf-8")).hexdigest()) endbb = self.IRB.append_basic_block(hashlib.sha256((bb_name + ".endif").encode("utf-8")).hexdigest()) #ifb = self.IRB.append_basic_block(bb_name + ".if") #elseb = self.IRB.append_basic_block(bb_name + ".else") #endb = self.IRB.append_basic_block(bb_name + ".endif") self.IRB.cbranch(cond, ifbb, elsebb) self.IRB.position_at_end(ifbb) ifv,ifb = EX.visit(e.a, self) self.IRB.branch(endbb) empty = False inst = selfbb.terminator.operands[-2].instructions if len(inst) == 1 and inst[0].opname == "br": empty = True try: assert inst[0].operands[0] == endbb self.IRB.position_at_end(selfbb) selfbb.terminator.operands[-2] = endbb selfbb.function.blocks.remove(ifbb) debug_temp = selfbb except: debug_temp = inst print("Except") empty = False self.IRB.position_at_end(elsebb) elsev,elseb = EX.visit(e.b, self) self.IRB.branch(endbb) self.IRB.position_at_end(endbb) ret = self.IRB.phi(IntType(e.nbits)) if not empty: ret.add_incoming(ifv, ifb) else: ret.add_incoming(ifv, selfbb) ret.add_incoming(elsev, elseb) return ret,endbb def llvm_get_target(triple_or_target=None): global __llvm_initialized if not __llvm_initialized: # Lazy initialisation llvm.initialize() llvm.initialize_all_targets() llvm.initialize_all_asmprinters() __llvm_initialized = True if isinstance(triple_or_target, llvm.Target): return triple_or_target if triple_or_target is None: return llvm.Target.from_default_triple() return llvm.Target.from_triple(triple_or_target) def _create_execution_engine(M, target): target_machine = target.create_target_machine() engine = llvm.create_mcjit_compiler(M, target_machine) return engine def to_llvm_ir(exprs, sym_to_value, IRB): if not llvmlite_available: raise RuntimeError("llvmlite module unavailable! can't assemble to LLVM IR...") if not isinstance(exprs, collections.abc.Iterable): exprs = (exprs,) ret = None visitor = ToLLVMIr(sym_to_value, IRB) for e in exprs: e = lower_rol_ror(e) ret = visitor.visit_value(e) return ret def to_llvm_function(exprs, vars_, name="__arybo"): if not llvmlite_available: raise RuntimeError("llvmlite module unavailable! can't assemble to LLVM IR...") if not isinstance(exprs, collections.abc.Iterable): exprs = (exprs,) M = ll.Module() args_types = [IntType(v.nbits) for v in vars_] fntype = ll.FunctionType(IntType(exprs[-1].nbits), args_types) func = ll.Function(M, fntype, name=name) func.attributes.add("nounwind") BB = func.append_basic_block() IRB = ll.IRBuilder() IRB.position_at_end(BB) sym_to_value = {} for i,v in enumerate(vars_): arg = func.args[i] arg.name = v.name sym_to_value[v.name] = arg ret = to_llvm_ir(exprs, sym_to_value, IRB) IRB.ret(ret) return M M = to_llvm_function(e,[in0.v, in1.v]) open("m2.ll", "w").write(str(M).replace('__arybo', 'func').replace('unknown-unknown-unknown', 'x86_64-pc-linux-gnu'))
上面 func2 和 stack2 都是 dump 下来的栈和函数代码的区域, 对于每一次循环都 dump 一次, 这里用的第二次循环的数据. 总之, 把 Triton 的 AST 转换成 LLVM IR 之后, 加个 main.c 编译一下就可以得到开头的结果了.
如果还有下一步的话我可能会做些什么
当然是膜大爷啦!
diff 了一下不同次循环, 代码都是类似的, 只有数据不同, 理论上只要能解一个循环就可以了. 看了下 IDA 的 F5 结果, 有 Feistel 结构的影子. 大概三种方法, 一种是模式匹配, 可以用正则也可以用 binary ninja 的 HLIL 之类的, 因为代码有很强的规律性. 第二种方法是用 reachable 和 liveness 的分析, 把每一层 Feistel 的两个变量找出来. 第三种方法的话... 就是找 Pizza 秒了他!
为什么不找大爷
被大爷发现我这么菜会被踢出粉丝团的.
用小标题再次谴责大佬们这几天疯狂划水的行为!!!
为什么不用大标题? 因为弱鸡只敢小声BB
[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课