fix(nyai): M35 评审 r1 — 闭合 exFAT 大小写绕过(P1)+ 修 fmt_utc 1/2 月 + 成员名/炸弹纵深

P1(信任边界):is_system_pkg() 原用 strncmp,而落盘目录在 exFAT(名字比较
不区分大小写)。包内 app.json 写 "OS.MEOW.READER" 时 valid_pkg 放行、
is_system_pkg 返回 0 → 当第三方无签放行 → dst 解析到已装的
MeowAPPs/os.meow.reader → 备份后部署假包并删掉真系统 App(无需私钥)。
三层封堵:
  1) is_system_pkg 改 strncasecmp(前缀判定不再受大小写影响);
  2) valid_pkg 收紧为只收 [a-z0-9._-],并拒绝前导/尾随 '.'(现网包名全小写,
     无兼容代价;tail-dot 同时挡 exFAT/Windows 名字映射怪癖);
  3) 部署前 readdir 检查 MeowAPPs 里是否有与 pkg 仅大小写不同的已装目录
     (有则拒装并报两个名字);部署后再用 readdir 复核 on-disk 名字与清单包名
     逐字节相等,不等则回滚,杜绝「身份不明的 App 目录」。

P2-1:fmt_utc() 的 March-based 月份换算写成 +9(应为 -9),1/2 月会打成
20251901/20251931 且年份不 +1。修正为 int 运算;新增 test_fmt_utc.c
(直接 include nyai.c 测 static 函数)覆盖 epoch/负数、1/1、2/1、2/28、闰年
2/29(2024 与 2000)、3/1、12/31 23:59:59、2038-01-01、int32 上界、2100 非闰。

纵深防御(评审残留风险,一并做):
- tar 成员名净化不再依赖外部 tar:mpk_check_members 解析 tar -tv 的名字,
  拒绝绝对路径 / 含 '..' 组件 / 首字符 '-' / 原始控制字符;解包后 stg_tree_ok
  再用 readdir 的真实名字复核控制字符(tar 列名会把控制字符转义成可见文本,
  原检查会漏)。
- 解压炸弹:累加 tar -tv 的体积,超过 512MB 上限拒装,并加 statvfs
  「剩余空间 ≥ 体积 + 16MB」门禁(解包先于验签,恶意包可在验签前写满卡)。
- 顺手改掉 appmeta.package 注释里「== 文件名茎」的旧语义。

回归:test_nyai.sh 49 → 70 条断言全过(新增:fmt_utc 13 例、大写包名绕过顶替
已装系统包被拒且原 App 未变、尾点包名、与已装目录仅大小写不同、绝对路径/
../ / 首字符 '-' 成员名、控制字符成员名、700MB 解压炸弹),
`-Wall -Wextra` 零告警,二进制可重复构建。

交付:nyai 153,844 B / md5 ef7940ecf5ece993d718ac1d9019cc82(旧 4524ce6b…)。
真机 10.58.215.50 仍不可登录(22 端口 TCP 可连、15-50s 无 banner),本轮
**未做真机复核**,仅宿主验证。
This commit is contained in:
AskaEth committed 2026-11-10 21:42:37 +08:00
1 parent 829d4475e8
commit cf5703b32b
5 files changed
+364 -26

No files matched your search

+113
View File
@@ -71,6 +71,27 @@ mkpkg() { # mkpkg <dir> <package> <version_code>
printf '#!/bin/sh\nexit 0\n' > "$1/bin"; chmod +x "$1/bin"
}
pack() { tar czf "$2" -C "$1" .; }
mk_evil_tar() { # mk_evil_tar <out.mpk> <member-name> <package>:成员名可含 ../ 或绝对路径
python3 - "$1" "$2" "$3" <<'PY'
import sys, tarfile, io, gzip
out, name, pkg = sys.argv[1], sys.argv[2], sys.argv[3]
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode='w') as t:
ti = tarfile.TarInfo(name); ti.size = 3; t.addfile(ti, io.BytesIO(b'pwn'))
j = ('{"name":"X","package":"%s","exec":"x","version":"1","version_code":1}' % pkg).encode()
tj = tarfile.TarInfo('app.json'); tj.size = len(j); t.addfile(tj, io.BytesIO(j))
open(out, 'wb').write(gzip.compress(buf.getvalue()))
PY
}
mk_bomb_tar() { # mk_bomb_tar <out.mpk>:成员头声明 700MB 的解压炸弹
python3 - "$1" <<'PY'
import sys, tarfile, gzip
ti = tarfile.TarInfo('big.bin'); ti.size = 700 * 1024 * 1024
ti.mtime = 0; ti.mode = 0o644; ti.uid = ti.gid = 0
hdr = ti.tobuf(format=tarfile.USTAR_FORMAT)
open(sys.argv[1], 'wb').write(gzip.compress(hdr + b'\0' * 1024))
PY
}
sign() { # sign <pkg> <mpk>:文件名茎必须等于包名(meow_sign 约定),签完覆盖原文件
[ -n "$KEY" ] || return 2
mkdir -p "$WORK/sign"
@@ -95,6 +116,17 @@ expect() { # expect <label> <cond...>
echo "== tests =="
# ---- 0 fmt_utc 边界(直接 include nyai.c 的 static 函数,2026-01/02 曾算错) ----
if "$CC" -Wall -Wextra "$HERE/test_fmt_utc.c" -o "$WORK/tools/test_fmt_utc" 2>"$WORK/fmt_utc.build.log"; then
if "$WORK/tools/test_fmt_utc" >"$WORK/fmt_utc.log" 2>&1; then
ok "0 fmt_utc 边界($(cat "$WORK/fmt_utc.log"))"
else
bad "0 fmt_utc 边界" "$(cat "$WORK/fmt_utc.log")"
fi
else
bad "0 fmt_utc 编译" "$(cat "$WORK/fmt_utc.build.log")"
fi
# ---- 1 文件名与包名完全不同 ----
r=$(sb 1); mkpkg "$WORK/b1" os.meow.demo 1; pack "$WORK/b1" "$WORK/p1.mpk"
if sign os.meow.demo "$WORK/p1.mpk"; then
@@ -238,6 +270,87 @@ expect "9d 清单包名 ≠ upgrade 目标 → 拒" \
sh -c 'test $1 -ne 0 && printf "%s" "$2" | grep -q "与目标包名(dev.local.up) 不一致"' _ "$rc" "$out"
expect "9e 以上失败均无残留" tmp_clean "$r"
# ---- 10 评审 r1 回归:大小写绕过(P1)/ 尾点 / 名字冲突 / 成员名 / 解压炸弹 ----
# 10a 先装一个合法签名系统包,再试图用大写包名顶掉它(exFAT 名字不区分大小写)
if [ -n "$KEY" ]; then
r=$(sb 10a); mkpkg "$WORK/b10a" os.meow.demo 1; pack "$WORK/b10a" "$WORK/demo10a.mpk"
sign os.meow.demo "$WORK/demo10a.mpk"
nyai_run "$r" install "$WORK/demo10a.mpk" >/dev/null 2>&1
before=$(md5sum "$r/MeowAPPs/os.meow.demo/app.json" | cut -d' ' -f1)
mkpkg "$WORK/b10ae" "OS.MEOW.DEMO" 99; pack "$WORK/b10ae" "$WORK/evil-case.mpk"
out=$(nyai_run "$r" install "$WORK/evil-case.mpk" 2>&1); rc=$?
show "$out"
after=$(md5sum "$r/MeowAPPs/os.meow.demo/app.json" | cut -d' ' -f1)
expect "10a 大写包名 OS.MEOW.DEMO 被拒(valid_pkg 只收小写)" test $rc -ne 0
expect "10a 错误文案指向 app.json.package 非法" \
sh -c 'printf "%s" "$1" | grep -q "app.json.package 非法"' _ "$out"
expect "10a 已装 os.meow.demo 未被顶掉(app.json 未变)" test "$before" = "$after"
expect "10a 未生成大写目录" test ! -e "$r/MeowAPPs/OS.MEOW.DEMO"
expect "10a 无残留" tmp_clean "$r"
else
skip "10a 大小写绕过" "无签名私钥"
fi
# 10b 尾点包名(exFAT/Windows 尾点映射怪癖)
r=$(sb 10b); mkpkg "$WORK/b10b" "dev.test." 1; pack "$WORK/b10b" "$WORK/trailing-dot.mpk"
out=$(nyai_run "$r" install "$WORK/trailing-dot.mpk" 2>&1); rc=$?
show "$out"
expect "10b 尾点包名 dev.test. 被拒" \
sh -c 'test $1 -ne 0 && printf "%s" "$2" | grep -q "app.json.package 非法"' _ "$rc" "$out"
expect "10b 无残留" tmp_clean "$r"
# 10c 已存在仅大小写不同的目录 → 部署前拦(exFAT 上会顶掉已装 App)
r=$(sb 10c); mkdir -p "$r/MeowAPPs/DEV.TEST.CLASH"
mkpkg "$WORK/b10c" dev.test.clash 1; pack "$WORK/b10c" "$WORK/clash.mpk"
out=$(nyai_run "$r" install "$WORK/clash.mpk" 2>&1); rc=$?
show "$out"
expect "10c 与已装目录仅大小写不同 → 拒装" \
sh -c 'test $1 -ne 0 && printf "%s" "$2" | grep -q "仅大小写不同"' _ "$rc" "$out"
expect "10c 已存在的目录未被改动" test -d "$r/MeowAPPs/DEV.TEST.CLASH"
expect "10c 无残留" tmp_clean "$r"
# 10d tar 成员名净化(不依赖外部 tar 的路径处理)
if command -v python3 >/dev/null 2>&1; then
for nm in "/tmp/evil.txt:dev.evil.abs" "../evil.txt:dev.evil.dd" "-evil.txt:dev.evil.dash"; do
member=${nm%%:*}; pkg=${nm##*:}
r=$(sb "10d-$pkg")
mk_evil_tar "$WORK/evil-member.mpk" "$member" "$pkg"
out=$(nyai_run "$r" install "$WORK/evil-member.mpk" 2>&1); rc=$?
show "$out"
expect "10d [$member] 成员名非法 → 拒装" \
sh -c 'test $1 -ne 0 && printf "%s" "$2" | grep -q "包成员路径非法"' _ "$rc" "$out"
expect "10d [$member] 无残留且未落盘" test ! -e "$r/MeowAPPs/$pkg" -a -z "$(find "$r/MeowOS/tmp/pkgs" -mindepth 1 2>/dev/null)"
done
# 10e 控制字符成员名(tar -tv 可能把它转义成可见文本 → 靠解包后的 on-disk 名字复核)
r=$(sb 10e)
python3 - "$WORK/evil-ctrl.mpk" <<'PY'
import sys, tarfile, io, gzip
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode='w') as t:
ti = tarfile.TarInfo('ev\x01il.txt'); ti.size = 3; t.addfile(ti, io.BytesIO(b'pwn'))
j = b'{"name":"X","package":"dev.evil.ctrl","exec":"x","version":"1","version_code":1}'
tj = tarfile.TarInfo('app.json'); tj.size = len(j); t.addfile(tj, io.BytesIO(j))
open(sys.argv[1], 'wb').write(gzip.compress(buf.getvalue()))
PY
out=$(nyai_run "$r" install "$WORK/evil-ctrl.mpk" 2>&1); rc=$?
show "$out"
expect "10e 控制字符成员名 → 拒装" \
sh -c 'test $1 -ne 0 && printf "%s" "$2" | grep -q "控制字符"' _ "$rc" "$out"
expect "10e 无残留" tmp_clean "$r"
# 10f 解压炸弹:成员头声明 700MB(上限 512MB)
r=$(sb 10f)
mk_bomb_tar "$WORK/bomb.mpk"
out=$(nyai_run "$r" install "$WORK/bomb.mpk" 2>&1); rc=$?
show "$out"
expect "10f 解压炸弹(700MB)→ 拒装" \
sh -c 'test $1 -ne 0 && printf "%s" "$2" | grep -q "防解压炸弹"' _ "$rc" "$out"
expect "10f 无残留" tmp_clean "$r"
else
skip "10d-f 成员名/解压炸弹" "无 python3"
fi
# ---------------- 汇总 ----------------
printf '\n== summary: %d passed, %d failed, %d skipped ==\n' "$PASS" "$FAIL" "$SKIP"
[ "$FAIL" -eq 0 ] || exit 1