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

Binary file not shown.
+199 -26
View File
@@ -22,6 +22,7 @@
#include <stdlib.h>
#include <stdarg.h>
#include <string.h>
#include <strings.h>
#include <ctype.h>
#include <unistd.h>
#include <errno.h>
@@ -32,10 +33,11 @@
#include <stdint.h>
#include <time.h>
#include <sys/stat.h>
#include <sys/statvfs.h>
#include <sys/types.h>
#include <sys/wait.h>
#define NYAI_VERSION "0.3.0"
#define NYAI_VERSION "0.3.1"
#define BUF 1024
#define MAX_SRC 16
#define MAX_PKG 256
@@ -46,6 +48,11 @@
* 每次安装一个子目录 <pkg>-<UTC 时间戳>-<pid>-<n>,无论成败都删。 */
#define STG_SUBDIR "MeowOS/tmp/pkgs"
/* 单包解压后体积上限(防解压炸弹:解包先于验签,恶意包可以在验签失败前把卡写满)。
* 现网最大 App ≈ 6MB(ncm),512MB 留足余量;另加 statvfs 余量检查。 */
#define MAX_UNPACK_BYTES (512LL * 1024 * 1024)
#define STG_SPACE_MARGIN (16LL * 1024 * 1024)
/* .mpk 签名 footer 布局(与 patch_v2/meow_verify/meow_sig.h 同步;此处只读 package 用于
* 「footer.package vs app.json.package」一致性检查——真正的验签仍由 meow_verify 执行)。 */
#define SIG_MAGIC "MEOWSIG1"
@@ -111,13 +118,20 @@ static void strip_comment(char *s)
if (p) *p = 0;
}
/* 包名合法:仅 [A-Za-z0-9._-],且拒绝 "." / ".."(否则 MeowAPPs/<pkg> 会越出安装目录)。 */
/* 包名合法:仅 [a-z0-9._-],且拒绝 "." / ".." / 前导或尾随 '.'。
* 只收小写是安全要求:SD 卡是 exFAT,名字比较不区分大小写,
* 允许大写就能用 "OS.MEOW.READER" 落到已装的 MeowAPPs/os.meow.reader 上
* 并绕过 is_system_pkg() 的签名门(现网包名全小写,收紧无兼容代价)。 */
static int valid_pkg(const char *s)
{
if (!s || !*s) return 0;
if (!strcmp(s, ".") || !strcmp(s, "..")) return 0;
for (; *s; s++)
if (!(isalnum((unsigned char)*s) || *s=='.' || *s=='-' || *s=='_')) return 0;
if (s[0] == '.') return 0; /* "." / ".." / 隐藏名 */
size_t n = strlen(s);
if (s[n-1] == '.') return 0; /* exFAT/Windows 尾点映射怪癖 */
for (; *s; s++) {
unsigned char c = (unsigned char)*s;
if (!(islower(c) || isdigit(c) || c=='.' || c=='-' || c=='_')) return 0;
}
return 1;
}
static int unsafe_path(const char *s)
@@ -127,6 +141,8 @@ static int unsafe_path(const char *s)
/* 前向声明:错误前缀(install/upgrade),定义见安装期安全设施一节。 */
static void op_err(const char *fmt, ...);
/* 前向声明:独立 '..' 路径组件判定,定义见导出过滤一节(安装期成员名检查也用)。 */
static int has_dotdot_comp(const char *p);
static int mkdir_p(const char *path)
{
@@ -214,10 +230,10 @@ static void fmt_utc(long long sec, char *out, int n)
unsigned doy = doe - (365*yoe + yoe/4 - yoe/100);
unsigned mp = (5*doy + 2)/153;
unsigned d = doy - (153*mp+2)/5 + 1;
unsigned m = mp + (mp < 10 ? 3 : 9); /* 1..12 */
int m = (int)mp + (mp < 10 ? 3 : -9); /* March-based → 1..12 */
if (m <= 2) y++;
long long rem = sec % 86400;
snprintf(out, n, "%04lld%02u%02u-%02u%02u%02u", y, m, d,
snprintf(out, n, "%04lld%02d%02u-%02u%02u%02u", y, m, d,
(unsigned)(rem/3600), (unsigned)((rem%3600)/60), (unsigned)(rem%60));
}
@@ -366,7 +382,7 @@ typedef struct {
long long version_code; /* app.json "version_code"(正整数,安装/升级比较唯一依据;缺时 has_=0) */
int has_version_code; /* app.json 是否显式声明 version_code */
char exec[512];
char package[64]; /* app.json "package"(权威包名,== 文件名茎;II 期绑定校验用) */
char package[64]; /* app.json "package"(唯一权威包名;M35 后与文件名无关) */
char author[128];
char routes[512];
char services[512];
@@ -860,10 +876,14 @@ static int cmd_list_upgradable(pkgrec *recs, int nrec)
}
/* ---------------- II 期签名校验(MPK_SIGNATURE_DESIGN §5.1,shell-out meow_verify) ---------------- */
/* 系统包前缀:os.meow.*(防伪造系统包名;独立于 valid_pkg,不改变现有合法包名序列)。 */
/* 系统包前缀:os.meow.*(防伪造系统包名)。
* **必须大小写不敏感**:安装落盘目录在 exFAT 上(名字比较不区分大小写),
* 若这里区分大小写,"OS.MEOW.READER" 会被当成第三方放行并落到已装的
* MeowAPPs/os.meow.reader 上,直接顶掉系统 App(valid_pkg 也已收紧为只收小写,
* 这里是第二道防线:任一改动失效都不会打开这个口子)。 */
static int is_system_pkg(const char *pkg)
{
return pkg && !strncmp(pkg, "os.meow.", 8);
return pkg && !strncasecmp(pkg, "os.meow.", 8);
}
/* meow_verify 二进制路径:环境变量 MEOW_VERIFY_BIN 覆盖(宿主测试用),缺省 /usr/bin/meow_verify。 */
@@ -1063,36 +1083,106 @@ static void scrub(char *s)
}
}
/* 解包前逐条检查归档成员类型(tar -tv 首列):只允许普通文件 '-' 与目录 'd'。
* 符号链接/硬链接/设备/FIFO 一律拒绝(防「先建链、后续成员写穿到目录外」)。
* 名字穿越交由 tar 自身剥离(busybox 1.31 真机实测:'../x'→'x'、'/x'→'x');
* 解包后再用 stg_tree_ok() 复核暂存树。
* 返回 0 通过 / -2 成员类型非法 / -3 空归档 / -1 读失败。 */
static int mpk_check_members(const char *mpk)
/* 解析一行 `tar -tv`:`<mode> <owner/group> <size> <date> <time> <name...>`。
* GNU tar 与 busybox tar 都是这 5 个固定字段,名字取余下整段(可含空格)。
* 返回 0 成功;-1 行格式不认识(size 非纯数字)。 */
static int tar_tv_parse(char *line, char *name, int n, long long *sz)
{
char *p = line;
char tok[5][64];
for (int i = 0; i < 5; i++) {
while (*p == ' ' || *p == '\t') p++;
if (!*p || *p == '\n') return -1;
int o = 0;
while (*p && *p != ' ' && *p != '\t' && *p != '\n') {
if (o < (int)sizeof tok[0] - 1) tok[i][o++] = *p;
p++;
}
tok[i][o] = 0;
}
for (char *q = tok[2]; *q; q++)
if (*q < '0' || *q > '9') return -1;
if (!tok[2][0]) return -1;
while (*p == ' ' || *p == '\t') p++;
if (!*p) return -1;
char *e = p + strlen(p);
while (e > p && (e[-1] == '\n' || e[-1] == '\r')) *--e = 0;
if ((int)strlen(p) + 1 > n) return -1;
snprintf(name, n, "%s", p);
*sz = atoll(tok[2]);
return 0;
}
/* 解包前逐条检查归档成员:类型只允许普通文件 '-' 与目录 'd'(拒符号链接/硬链接/
* 设备/FIFO);名字自己做净化,不依赖外部 tar 的路径处理——名称含 '..' 组件、
* 绝对路径、控制字符、或首字符 '-' 一律拒装。同时累加成员体积供解压炸弹门禁用。
* 返回 0 通过 / -1 读失败 / -2 成员类型非法 / -3 空归档 / -4 成员名非法 / -5 行格式不认识。 */
static int mpk_check_members(const char *mpk, long long *total_out)
{
char cmd[BUF];
snprintf(cmd, sizeof cmd, "gzip -dc '%s' 2>/dev/null | tar -tv 2>/dev/null", mpk);
FILE *pf = popen(cmd, "r");
if (!pf) return -1;
char line[1200];
int n = 0, bad = 0;
char line[1200], name[1024];
int n = 0, rc = 0;
long long total = 0;
while (fgets(line, sizeof line, pf)) {
n++;
if (line[0] != '-' && line[0] != 'd') {
char *e = line + strlen(line);
while (e > line && (e[-1] == '\n' || e[-1] == '\r')) *--e = 0;
op_err("包内含链接/特殊文件(仅允许普通文件与目录),拒绝安装: %s\n", line);
bad = 1;
rc = -2;
break;
}
long long sz = 0;
if (tar_tv_parse(line, name, sizeof name, &sz) != 0) {
op_err("无法解析包成员列表(不支持的 tar 输出格式),拒绝安装\n");
rc = -5;
break;
}
if (name[0] == '/' || name[0] == '-' || has_dotdot_comp(name)) {
op_err("包成员路径非法(绝对路径/.. 穿越/首字符 '-'),拒绝安装: %s\n", name);
rc = -4;
break;
}
for (const char *q = name; *q; q++) {
unsigned char c = (unsigned char)*q;
if (c < 0x20 || c == 0x7f) {
op_err("包成员名含控制字符,拒绝安装\n");
rc = -4;
break;
}
}
if (rc) break;
if (total > LLONG_MAX - sz) { rc = -5; break; }
total += sz;
}
pclose(pf);
if (bad) return -2;
return n ? 0 : -3;
if (rc) return rc;
if (!n) return -3;
if (total_out) *total_out = total;
return 0;
}
/* 解包后复核暂存树:只允许普通文件与目录;普通文件的 st_nlink 必须为 1
* (硬链接到外部文件会表现为 nlink>1)。0=通过。 */
/* 暂存所在文件系统余量检查(解包体积 + 16MB 余量)。statvfs 不可用时不阻断。 */
static int stg_space_ok(const char *base, long long need)
{
struct statvfs vfs;
if (statvfs(base, &vfs) != 0) return 0;
long long avail = (long long)vfs.f_bavail * (long long)vfs.f_frsize;
if (avail <= 0) return 0;
if (need + STG_SPACE_MARGIN > avail) {
op_err("SD 卡剩余空间不足:需要约 %lld MB + 16MB 余量,可用 %lld MB\n",
(need + 1048575) / 1048576, avail / 1048576);
return -1;
}
return 0;
}
/* 解包后复核暂存树:只允许普通文件与目录、普通文件 nlink 必须为 1
* (硬链接到外部文件会表现为 nlink>1);名字里不得有控制字符
* (tar 列名时可能把控制字符转义成可见文本,这里用 readdir 的真实 on-disk 名字复核)。 */
static int stg_tree_ok(const char *path)
{
struct stat st;
@@ -1104,6 +1194,11 @@ static int stg_tree_ok(const char *path)
struct dirent *de;
while ((de = readdir(d))) {
if (!strcmp(de->d_name,".") || !strcmp(de->d_name,"..")) continue;
for (const char *q = de->d_name; *q; q++) {
unsigned char c = (unsigned char)*q;
if (c < 0x20 || c == 0x7f) { ok = 0; break; }
}
if (!ok) break;
char p[BUF];
if (snprintf(p, sizeof p, "%s/%s", path, de->d_name) >= (int)sizeof p) { ok = 0; break; }
if (!stg_tree_ok(p)) { ok = 0; break; }
@@ -1159,6 +1254,52 @@ static int stg_rename_pkg(const char *base, const char *prep, const char *pkg, c
return -1;
}
/* MeowAPPs 下是否有与 pkg 仅大小写不同的已存在条目(exFAT 上两者是同一个 dentry)。
* 返回 1=有冲突(other 回传该条目名)/ 0=无 / -1=目录打不开(视为无)。 */
static int meowapps_name_conflict(const char *pkg, char *other, int n)
{
char dir[BUF];
if (snprintf(dir, sizeof dir, "%s/MeowAPPs", meow_root()) >= (int)sizeof dir) return -1;
DIR *d = opendir(dir);
if (!d) return -1;
int hit = 0;
struct dirent *de;
while ((de = readdir(d))) {
if (!strcmp(de->d_name, ".") || !strcmp(de->d_name, "..")) continue;
if (!strcmp(de->d_name, pkg)) continue; /* 逐字节相同 = 正常的重装 */
if (strcasecmp(de->d_name, pkg) == 0) {
if (other && n > 0) snprintf(other, n, "%s", de->d_name);
hit = 1;
break;
}
}
closedir(d);
return hit;
}
/* path 的父目录里是否存在**逐字节**等于 path 末段名字的条目(不依赖文件系统的大小写/
* 尾点/尾空格映射:用 readdir 读回真实 on-disk 名字再比)。1=存在 0=不存在。 */
static int path_exact_entry(const char *path)
{
const char *slash = strrchr(path, '/');
if (!slash || !slash[1]) return 0;
char dir[BUF];
size_t dl = (size_t)(slash - path);
if (dl == 0 || dl >= sizeof dir) return 0;
memcpy(dir, path, dl);
dir[dl] = 0;
const char *want = slash + 1;
DIR *d = opendir(dir);
if (!d) return 0;
int hit = 0;
struct dirent *de;
while ((de = readdir(d))) {
if (!strcmp(de->d_name, want)) { hit = 1; break; }
}
closedir(d);
return hit;
}
/* ---------------- install(文件或名称) ---------------- */
/* 安装/升级一个 .mpk。**包名、版本、依赖全部来自包内 app.json**,文件名与后缀不参与身份判定。
* expect_pkg != NULL → 清单包名必须等于它(upgrade / 索引安装);
@@ -1183,8 +1324,9 @@ static int install_mpk_file(const char *mpk_path, const char *expect_pkg, int mu
}
snprintf(g_stg, sizeof g_stg, "%s", prep);
/* ② 解包前检查归档成员类型 */
int mr = mpk_check_members(mpk_path);
/* ② 解包前检查归档成员(类型 + 名字净化)并统计解压后体积 */
long long unpack_bytes = 0;
int mr = mpk_check_members(mpk_path, &unpack_bytes);
stg_check_signal();
if (mr != 0) {
if (mr == -3) op_err("%s 解不出内容(不是 .mpk / 空归档)\n", mpk_path);
@@ -1192,6 +1334,16 @@ static int install_mpk_file(const char *mpk_path, const char *expect_pkg, int mu
stg_cleanup();
return 1;
}
if (unpack_bytes > MAX_UNPACK_BYTES) {
op_err("包解压后体积约 %lld MB,超过 %lld MB 上限,拒绝安装(防解压炸弹)\n",
(unpack_bytes + 1048575) / 1048576, (long long)(MAX_UNPACK_BYTES / 1048576));
stg_cleanup();
return 1;
}
if (stg_space_ok(base, unpack_bytes) != 0) {
stg_cleanup();
return 1;
}
/* ③ 解包到暂存目录(SD 卡,不占 /usr/data 的 4MB NAND) */
printf("Preparing to unpack %s ...\n", mpk_path);
snprintf(cmd, sizeof cmd, "gzip -dc '%s' 2>/dev/null | tar -x -C '%s' 2>/dev/null", mpk_path, prep);
@@ -1203,7 +1355,7 @@ static int install_mpk_file(const char *mpk_path, const char *expect_pkg, int mu
}
stg_check_signal();
if (!stg_tree_ok(prep)) {
op_err("解压结果含符号链接/硬链接等危险条目,拒绝安装\n");
op_err("解压结果含符号链接/硬链接/控制字符等危险条目,拒绝安装\n");
stg_cleanup();
return 1;
}
@@ -1291,6 +1443,18 @@ static int install_mpk_file(const char *mpk_path, const char *expect_pkg, int mu
if (snprintf(dst, sizeof dst, "%s/MeowAPPs/%s", meow_root(), pkg) >= (int)sizeof dst) {
op_err("包名过长\n"); stg_cleanup(); return 1;
}
/* exFAT 名字比较不区分大小写:若已装目录与 pkg 仅大小写不同,dst 会解析到那个
* 已装 App 上(顶掉它)。正常路径由 valid_pkg 只收小写挡住,这里是第二道。 */
{
char clash[128] = "";
int c = meowapps_name_conflict(pkg, clash, sizeof clash);
if (c == 1) {
op_err("安装目录名 %s 与已装 %s 仅大小写不同(SD 卡 exFAT 不区分大小写,会顶掉它),拒绝安装\n",
pkg, clash);
stg_cleanup();
return 1;
}
}
snprintf(bak, sizeof bak, "%s.bak_%d", dst, (int)getpid());
int replacing = (access(dst, F_OK) == 0);
if (must_be_installed && !replacing) {
@@ -1330,6 +1494,15 @@ static int install_mpk_file(const char *mpk_path, const char *expect_pkg, int mu
if (replacing) rename(bak, dst); /* 回滚到旧版 */
return 1;
}
/* 部署后复核 on-disk 名字与清单包名逐字节相等(exFAT 的尾点/尾空格/大小写映射
* 若把目录名改成了别的,宁可回滚也不留一个身份不明的 App 目录)。 */
if (!path_exact_entry(dst)) {
op_err("部署后的目录名与清单包名 %s 不完全一致(文件系统名字映射差异),回滚\n", pkg);
rm_rf(dst);
if (replacing) rename(bak, dst);
stg_cleanup();
return 1;
}
g_stg[0] = 0; /* 已变成正式目录,不再属于清理范围 */
/* ⑨ 数据 init(MeowData 数据保留,仅保证目录存在) */
Binary file not shown.
+52
View File
@@ -0,0 +1,52 @@
/* test_fmt_utc.c — fmt_utc() 边界单测(宿主)。
*
* fmt_utc 是 nyai.c 里的 static 函数;这里把 main 改名后直接 include 源码来测,
* 不引入测试框架、不复制实现(避免单测与实现漂移)。
* 覆盖:epoch/负数、1 月、2 月(闰/平)、3 月、12 月 31 日、闰年 2/29、2100 世纪闰年规则、
* 2038 int32 边界。真机时区无关(fmt_utc 固定 UTC)。
*
* 用法:cc -Wall -Wextra test_fmt_utc.c -o test_fmt_utc && ./test_fmt_utc
*/
#define main nyai_main_unused
#include "nyai.c"
#undef main
#include <stdio.h>
#include <string.h>
struct utc_case { long long t; const char *want; };
int main(void)
{
static const struct utc_case cases[] = {
{ 0LL, "t0" }, /* epoch 由调用方定义为「无时间」 */
{ -1LL, "t0" },
{ 1LL, "19700101-000001" },
{ 1LL * 1767225600, "20260101-000000" }, /* 2026-01-01(曾打成 20251901) */
{ 1769904000LL, "20260201-000000" }, /* 2026-02-01(曾打成 20251931) */
{ 1772236800LL, "20260228-000000" }, /* 2026-02-28(平年) */
{ 1709164800LL, "20240229-000000" }, /* 2024-02-29(闰年) */
{ 951782400LL, "20000229-000000" }, /* 2000-02-29(400 年闰) */
{ 4102444800LL, "21000101-000000" }, /* 2100-01-01(非闰世纪) */
{ 1772323200LL, "20260301-000000" }, /* 2026-03-01(March-based 起点) */
{ 1798761599LL, "20261231-235959" }, /* 年末最后一秒 */
{ 2145916800LL, "20380101-000000" },
{ 2147483647LL, "20380119-031407" }, /* int32 time_t 上界 */
};
int fail = 0;
for (unsigned i = 0; i < sizeof cases / sizeof cases[0]; i++) {
char got[32];
fmt_utc(cases[i].t, got, sizeof got);
if (strcmp(got, cases[i].want) != 0) {
printf("FAIL fmt_utc(%lld) = %s, want %s\n",
cases[i].t, got, cases[i].want);
fail++;
}
}
if (fail == 0) {
printf("PASS fmt_utc: %u cases\n", (unsigned)(sizeof cases / sizeof cases[0]));
return 0;
}
printf("FAILED %d/%u\n", fail, (unsigned)(sizeof cases / sizeof cases[0]));
return 1;
}
+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