dsh-pwsh-plugin 0.0.0-stage → 0.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/wsl/llrun.c ADDED
@@ -0,0 +1,337 @@
1
+ /*
2
+ * llrun —— WSL 里 bash 的写边界护栏(DSH bash 工具的 landlock 垫片)
3
+ *
4
+ * 背景
5
+ * DSH 的 bash 工具跑 WSL Debian,9p/drvfs 挂载不受 DSH 沙箱约束,
6
+ * /mnt/c、/mnt/d 任意位置都能写。本程序在 exec 目标 shell 之前加边界:
7
+ *
8
+ * 1) user + mount namespace(默认开,--no-isolate 关闭):
9
+ * 把危险 unix socket 换成 /dev/null。
10
+ * /run/docker.sock 这类 socket 不受 landlock 管,是唯一实测成功的逃逸口
11
+ * (docker run -v /mnt/d:/host 能直接把文件写到 Windows 盘上)。
12
+ * 代价:userns 只映射当前 uid,其余属主在 ns 内显示为 65534,
13
+ * 导致 ssh 之类的「配置文件属主必须正确」检查失败,sudo 也不可用。
14
+ * 2) landlock(ABI 7):文件系统白名单
15
+ * - 系统目录全权限(/usr /etc /var /tmp /dev /proc /sys /run /home ...)
16
+ * - /mnt/c /mnt/d /mnt/e 只读(EXECUTE | READ_FILE | READ_DIR)
17
+ * - 工作区可写(显式传入,或 "-" 表示当前工作目录;-r 降为只读)
18
+ * - 白名单不含 /init → WSL interop 失效 → cmd.exe / pwsh.exe / 任意 PE 全部无法执行
19
+ * (这是关键:interop 一开,所有写限制形同虚设,实测 cmd.exe 能直接写 D:\)
20
+ * 代价:landlock 要求 PR_SET_NO_NEW_PRIVS,setuid 提权(sudo)从此不可用。
21
+ * 3) /var/tmp 挂载封印(默认开):
22
+ * 在 userns 的 mountns 内 bind 自身后 remount,ro。
23
+ * landlock 封不住子目录——父子规则取并集,"/var 全权" 会吞掉 "/var/tmp 只读",
24
+ * 只有 mount 能精细封印。/var/tmp 是 ext4 磁盘、本机无程序依赖它,
25
+ * 封掉零代价(SemLock / SharedMemory / gcc / git / curl 实测全正常)。
26
+ * /tmp 与 /dev/shm 必须保留:/tmp 是所有工具的默认临时目录,
27
+ * /dev/shm 是 POSIX 共享内存挂载点,封掉后 python multiprocessing 直接崩。
28
+ *
29
+ * 用法
30
+ * llrun [--no-isolate] [--no-seal] [-r] [-C <dir>] <workspace|-> <prog> [args...]
31
+ * --no-isolate 不做 socket 掩蔽(docker 口子保持连通),同时也不做 /var/tmp 封印
32
+ * --no-seal 只关 /var/tmp 封印,socket 掩蔽照旧
33
+ * -r 严格只读(对应 DSH 的 read-only 模式):除 /dev 外全部目录降为
34
+ * 只读(读/列目录/执行照常,写入/新建/删除/重命名全部拒绝),
35
+ * 工作区同样只读。/dev 必须留全权——/dev/null 是字符设备,
36
+ * landlock 无法单独授权,封掉后连 "echo x > /dev/null" 都失败;
37
+ * 代价是 /dev/shm 被父规则并集带成可写,所以 -r 档额外用 mount 封它。
38
+ * -C <dir> landlock 生效后 chdir 到该目录(等价 cd,但不污染脚本的 $0 与行号)
39
+ * workspace 可写目录(WSL 路径);传 '-' 表示当前工作目录
40
+ * prog 目标程序,之后的 argv 原样传递(不经过任何 shell)
41
+ *
42
+ * 退出码
43
+ * exec 成功时进程直接变成 prog,退出码/信号/标准流全部透传。
44
+ * 126 = 护栏自身初始化失败(规则集创建或 restrict 失败);127 = exec 失败。
45
+ * 若 user namespace 不可用,打印警告后仍继续——landlock 依然生效,
46
+ * 只是 docker.sock 这类 unix socket 逃逸口没被封(降级而非失效)。
47
+ *
48
+ * 编译
49
+ * gcc -O2 -o llrun llrun.c
50
+ */
51
+
52
+ #define _GNU_SOURCE
53
+ #include <errno.h>
54
+ #include <fcntl.h>
55
+ #include <limits.h>
56
+ #include <sched.h>
57
+ #include <stdio.h>
58
+ #include <stdlib.h>
59
+ #include <string.h>
60
+ #include <unistd.h>
61
+ #include <sys/mount.h>
62
+ #include <sys/stat.h>
63
+ #include <sys/prctl.h>
64
+ #include <sys/syscall.h>
65
+ #include <linux/landlock.h>
66
+
67
+ /* 全权限目录:程序、库、配置、包数据库、临时区、设备、内核接口。 */
68
+ static const char *const FULL_ACCESS[] = {
69
+ "/usr", "/bin", "/sbin", "/lib", "/lib64", "/lib32",
70
+ "/etc", "/var", "/srv", "/media", "/root",
71
+ "/tmp", "/dev", "/proc", "/sys", "/run",
72
+ "/opt", "/home", "/usr/local", "/snap", "/boot",
73
+ NULL
74
+ };
75
+
76
+ /* 只读挂载点:Windows 盘整体只读,读取与列目录照常。 */
77
+ static const char *const READ_ONLY[] = {
78
+ "/mnt/c", "/mnt/d", "/mnt/e", "/mnt/wsl", "/mnt/wslg", "/mnt",
79
+ NULL
80
+ };
81
+
82
+ /* 需要封印成只读的目录:landlock 管不到子目录,只能靠 mount 层面降权。 */
83
+ static const char *const SEAL_RO[] = {
84
+ "/var/tmp",
85
+ NULL
86
+ };
87
+
88
+ /*
89
+ * -r 严格档额外要封的:/dev 必须全权(/dev/null 是字符设备),而 landlock 的父子规则
90
+ * 取并集,"/dev 全权" 会把 "/dev/shm 只读" 吞掉,只能靠 mount 降权。
91
+ */
92
+ static const char *const SEAL_RO_STRICT[] = {
93
+ "/dev/shm",
94
+ NULL
95
+ };
96
+
97
+ /* 需要屏蔽的 unix socket:连接它们等于把写操作外包给不受 landlock 约束的守护进程。 */
98
+ static const char *const MASK[] = {
99
+ "/run/docker.sock",
100
+ "/run/docker.pid",
101
+ "/var/run/docker.sock",
102
+ "/run/containerd/containerd.sock",
103
+ "/run/podman/podman.sock",
104
+ NULL
105
+ };
106
+
107
+ static int writeAll(const char *path, const char *data) {
108
+ int fd = open(path, O_WRONLY | O_CLOEXEC);
109
+ if (fd < 0) return -1;
110
+ ssize_t n = write(fd, data, strlen(data));
111
+ close(fd);
112
+ return n == (ssize_t)strlen(data) ? 0 : -1;
113
+ }
114
+
115
+ /*
116
+ * 进入 user + mount namespace 并把危险 socket 掩成 /dev/null。
117
+ * 必须在 landlock 之前做:landlock 不可回退,而 mount 需要权限。
118
+ */
119
+ static int isolateSockets(void) {
120
+ /* unshare 之后 getuid() 会变成 65534,真实 uid 必须提前取。 */
121
+ uid_t uid = getuid();
122
+ gid_t gid = getgid();
123
+ char map[64];
124
+
125
+ if (unshare(CLONE_NEWUSER) != 0) return -1;
126
+
127
+ writeAll("/proc/self/setgroups", "deny");
128
+ /*
129
+ * 同 uid 映射(1000 -> 1000)而不是 0 -> 1000:
130
+ * 后者会把调用者变成 ns 内 root,HOME 里的文件全部显示成 65534,
131
+ * ssh 读 ~/.ssh 直接 Permission denied;前者保持调用者身份,只丢了 root 的映射
132
+ * (sudo 本来就因 NO_NEW_PRIVS 不可用,无损失)。
133
+ */
134
+ snprintf(map, sizeof(map), "%d %d 1", (int)uid, (int)uid);
135
+ if (writeAll("/proc/self/uid_map", map) != 0) return -1;
136
+ snprintf(map, sizeof(map), "%d %d 1", (int)gid, (int)gid);
137
+ if (writeAll("/proc/self/gid_map", map) != 0) return -1;
138
+
139
+ if (unshare(CLONE_NEWNS) != 0) return -1;
140
+ if (mount(NULL, "/", "", MS_REC | MS_PRIVATE, NULL) != 0) return -1;
141
+
142
+ for (int i = 0; MASK[i] != NULL; i++) {
143
+ if (access(MASK[i], F_OK) == 0) mount("/dev/null", MASK[i], "", MS_BIND, NULL);
144
+ }
145
+
146
+ /*
147
+ * ssh 会检查 /etc/ssh/ssh_config.d 下每个 .conf 的属主必须是自己,
148
+ * 而 userns 里这些文件属于未映射的 root,一律显示 65534
149
+ * → ssh 直接 "Bad owner or permissions" 拒绝启动。
150
+ * 用一个空目录盖掉整个片段目录:ssh 回到内置默认配置,连接照常。
151
+ */
152
+ /*
153
+ * 用固定路径而不是 mkdtemp:mkdtemp 每次调用都留一个空目录在 /tmp,
154
+ * 几百次调用后就是几百个 .llrun-empty-* 垃圾(实测 421 个)。
155
+ * 固定路径在 ns 内 bind 到 /etc/ssh/ssh_config.d 后,进程退出 bind 消失、
156
+ * 宿主 /tmp 里只留这一个目录,且可以被复用。
157
+ */
158
+ static const char *const emptyDir = "/tmp/.llrun-empty";
159
+ if (mkdir(emptyDir, 0700) == 0 || errno == EEXIST) {
160
+ if (mount(emptyDir, "/etc/ssh/ssh_config.d", "", MS_BIND, NULL) != 0) {
161
+ /* 覆盖失败不致命,只是 ssh 会报属主警告。 */
162
+ }
163
+ }
164
+ return 0;
165
+ }
166
+
167
+ /*
168
+ * 把 SEAL_RO 里的目录 bind 到自身再 remount 成只读。
169
+ *
170
+ * 为什么必须用 mount 而不是 landlock:
171
+ * landlock 的父子规则取并集,"/var 全权" 会吞掉 "/var/tmp 只读" 这条更严格的规则
172
+ * (/dev 全权 + /dev/shm 只读同理,实测均仍可写)。
173
+ * 只有 mount 层的 ro 能精细封印子目录,而 remount 要求 CAP_SYS_ADMIN —— 只能在
174
+ * userns 内做,所以这个函数必须排在 unshare(CLONE_NEWUSER) 之后。
175
+ *
176
+ * 失败返回 -1,由调用方降级为警告(与 userns 不可用同样处理)。
177
+ */
178
+ static int sealOne(const char *dir) {
179
+ struct stat st;
180
+ if (stat(dir, &st) != 0 || !S_ISDIR(st.st_mode)) return 0;
181
+ if (mount(dir, dir, "", MS_BIND | MS_REC, NULL) != 0) return -1;
182
+ if (mount(NULL, dir, "", MS_REMOUNT | MS_BIND | MS_RDONLY | MS_NOSUID | MS_NODEV, NULL) != 0) {
183
+ return -1;
184
+ }
185
+ return 0;
186
+ }
187
+
188
+ static int sealReadOnlyDirs(int strict) {
189
+ for (int i = 0; SEAL_RO[i] != NULL; i++) {
190
+ if (sealOne(SEAL_RO[i]) != 0) return -1;
191
+ }
192
+ if (strict) {
193
+ for (int i = 0; SEAL_RO_STRICT[i] != NULL; i++) {
194
+ if (sealOne(SEAL_RO_STRICT[i]) != 0) return -1;
195
+ }
196
+ }
197
+ return 0;
198
+ }
199
+
200
+ static void allowPath(int ruleset, const char *path, __u64 access) {
201
+ int fd = open(path, O_PATH | O_CLOEXEC);
202
+ if (fd < 0) return;
203
+ struct landlock_path_beneath_attr rule;
204
+ memset(&rule, 0, sizeof(rule));
205
+ rule.allowed_access = access;
206
+ rule.parent_fd = fd;
207
+ syscall(445 /* LANDLOCK_ADD_RULE */, ruleset, LANDLOCK_RULE_PATH_BENEATH, &rule, 0);
208
+ close(fd);
209
+ }
210
+
211
+ int main(int argc, char **argv) {
212
+ int argi = 1;
213
+ int workspaceReadOnly = 0;
214
+ int isolate = 1;
215
+ int seal = 1;
216
+ const char *chdirTo = NULL;
217
+
218
+ for (;;) {
219
+ if (argi < argc && strcmp(argv[argi], "-r") == 0) {
220
+ workspaceReadOnly = 1;
221
+ argi++;
222
+ continue;
223
+ }
224
+ if (argi < argc && strcmp(argv[argi], "--no-isolate") == 0) {
225
+ isolate = 0;
226
+ argi++;
227
+ continue;
228
+ }
229
+ if (argi < argc && strcmp(argv[argi], "--no-seal") == 0) {
230
+ seal = 0;
231
+ argi++;
232
+ continue;
233
+ }
234
+ if (argi + 1 < argc && strcmp(argv[argi], "-C") == 0) {
235
+ chdirTo = argv[argi + 1];
236
+ argi += 2;
237
+ continue;
238
+ }
239
+ break;
240
+ }
241
+
242
+ if (argc - argi < 2) {
243
+ fprintf(stderr, "usage: llrun [--no-isolate] [--no-seal] [-r] [-C <dir>] <workspace|-> <prog> [args...]\n");
244
+ return 126;
245
+ }
246
+
247
+ /* 工作区路径必须在 landlock 之前解析:restrict 之后 /mnt 只剩只读权限。 */
248
+ char cwdBuf[PATH_MAX];
249
+ const char *workspace = argv[argi];
250
+ if (strcmp(workspace, "-") == 0) {
251
+ if (getcwd(cwdBuf, sizeof(cwdBuf)) == NULL) {
252
+ fprintf(stderr, "llrun: getcwd failed: %s\n", strerror(errno));
253
+ return 126;
254
+ }
255
+ workspace = cwdBuf;
256
+ }
257
+ /* 拒绝把根目录当工作区,否则等于没有边界。 */
258
+ if (workspace[0] != '/' || strlen(workspace) < 2) {
259
+ fprintf(stderr, "llrun: refusing workspace '%s'\n", workspace);
260
+ return 126;
261
+ }
262
+
263
+ int nsReady = 1;
264
+ if (isolate) {
265
+ if (isolateSockets() != 0) {
266
+ nsReady = 0;
267
+ fprintf(stderr,
268
+ "llrun: warning: user namespace unavailable; unix sockets (e.g. docker) stay reachable\n");
269
+ }
270
+ } else {
271
+ /* --no-isolate 没有 userns,也就没有 CAP_SYS_ADMIN,封印同样做不了。 */
272
+ nsReady = 0;
273
+ }
274
+
275
+ /*
276
+ * 封印必须在 landlock 之前:mount 需要权限,而 landlock restrict 之后
277
+ * 白名单外的路径连 stat 都可能失败。封印失败不致命,只降级并告警。
278
+ */
279
+ if (seal) {
280
+ if (!nsReady) {
281
+ if (isolate) {
282
+ fprintf(stderr, "llrun: warning: /var/tmp not sealed (no user namespace)\n");
283
+ }
284
+ } else if (sealReadOnlyDirs(workspaceReadOnly) != 0) {
285
+ fprintf(stderr, "llrun: warning: sealing failed: %s\n", strerror(errno));
286
+ }
287
+ }
288
+
289
+ /* landlock ABI 1..7 的位全开(bit0..bit14 覆盖到 REFER/TRUNCATE)。 */
290
+ __u64 all = 0;
291
+ for (int bit = 0; bit <= 14; bit++) all |= (1ULL << bit);
292
+ const __u64 readExecute = (1ULL << 0) | (1ULL << 2) | (1ULL << 3); /* EXECUTE|READ_FILE|READ_DIR */
293
+
294
+ struct landlock_ruleset_attr attr;
295
+ memset(&attr, 0, sizeof(attr));
296
+ attr.handled_access_fs = all;
297
+ int ruleset = syscall(444 /* LANDLOCK_CREATE_RULESET */, &attr, sizeof(attr), 0);
298
+ if (ruleset < 0) {
299
+ fprintf(stderr, "llrun: landlock unavailable: %s\n", strerror(errno));
300
+ return 126;
301
+ }
302
+
303
+ if (workspaceReadOnly) {
304
+ /*
305
+ * 严格只读:系统目录一律降到 readExecute(读文件 / 列目录 / 执行照常)。
306
+ * 读权限必须保留——没有它连 ls、cat、gcc 都起不来,read-only 会变成 unusable。
307
+ */
308
+ for (int i = 0; FULL_ACCESS[i] != NULL; i++) allowPath(ruleset, FULL_ACCESS[i], readExecute);
309
+ /* 唯一例外:/dev 全权。字符设备无法单独授权,见头注释。 */
310
+ allowPath(ruleset, "/dev", all);
311
+ } else {
312
+ for (int i = 0; FULL_ACCESS[i] != NULL; i++) allowPath(ruleset, FULL_ACCESS[i], all);
313
+ }
314
+ for (int i = 0; READ_ONLY[i] != NULL; i++) allowPath(ruleset, READ_ONLY[i], readExecute);
315
+
316
+ /* 工作区。注意 /init 永远不进白名单——这是封死 Windows exe 的关键。 */
317
+ allowPath(ruleset, workspace, workspaceReadOnly ? readExecute : all);
318
+
319
+ if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) {
320
+ fprintf(stderr, "llrun: PR_SET_NO_NEW_PRIVS failed: %s\n", strerror(errno));
321
+ return 126;
322
+ }
323
+ if (syscall(446 /* LANDLOCK_RESTRICT_SELF */, ruleset, 0) != 0) {
324
+ fprintf(stderr, "llrun: landlock restrict failed: %s\n", strerror(errno));
325
+ return 126;
326
+ }
327
+ close(ruleset);
328
+
329
+ if (chdirTo != NULL && chdir(chdirTo) != 0) {
330
+ fprintf(stderr, "llrun: chdir %s failed: %s\n", chdirTo, strerror(errno));
331
+ return 126;
332
+ }
333
+
334
+ execv(argv[argi + 1], &argv[argi + 1]);
335
+ fprintf(stderr, "llrun: exec %s failed: %s\n", argv[argi + 1], strerror(errno));
336
+ return 127;
337
+ }