fix(security): M64 P1 三条 root 命令注入 — 外部串改 execv 传 argv、电源键下线、press_ms clamp

P1-1 Wi-Fi SSID/PSK 注入(netmgrd + launcherd 回退两处同步):
- 不再拼 `set_network %d ssid '"%s"'` 交给 sh -c;新增 wpa_exec()/nm_direct_wpa_exec()
  以 fork+execv 传 argv(含 resolve_bin 解析绝对路径,替代 `PATH=...` 前缀)。
- 共用 nm_wifi_args.h:nm_wifi_ssid_ok/n_wifi_psk_ok/nm_wpa_quote/nm_wpa_set_argv,
  两份实现同一套规则(只修一处等于没修)。
- 约束层:SSID 1..32B、禁控制字符;PSK ≤63 或 64 hex、禁控制字符;`"`/`\` 按
  wpa_supplicant 语法转义。shell 元字符不再需要拒绝——execv 下没有 shell 解析。

P1-2 MeowPanel App 安装/导出/卸载注入:
- install/export/remove 改 run_argv()(fork+execv,PATH 只在子进程 setenv)。
- 包名统一 mp_pkg_ok()(仅 [a-z0-9._-],拒 `..`,与 nyai.c valid_pkg 同规则)→ 修 P2-7。
- `{path}` 追加 mp_path_unsafe()(与 mp_fonts/nyai 同一拒绝集)作第二道。

P1-3 /api/key/* 电源键:
- keycode_to_device 删除 KEY_POWER;116 进红线(403),前端遥控板电源/长按电源按钮移除。
- press_ms 全局 clamp 0..1000(单键与批量),`long` 缺省取上限。
- 按下→等待→抬起改独立线程(inject_key_async),不再阻塞单线程 8080 服务。

测试(宿主,反向控制):test_nm_wifi_args.c / test_mp_argsafe.c —— 同一套审计恶意串
(`a' ; id ; '`、`x';id;'`、`/tmp/a';id>/tmp/pwn;'.mpk`、`MeowAPPs/..`)在修复前实现下
注入成功(stdout 含 uid=)/放行,在修复后实现下不执行/拒绝。另含「两处实现同步」检查。

未刷机、未连设备、未发 /api/key 请求;只改源码,未提交二进制。
This commit is contained in:
AskaEth committed 2026-11-12 08:50:10 +08:00
1 parent 952a126477
commit 7b32d775c0
12 files changed
+812 -71

No files matched your search

+14 -5
View File
@@ -142,7 +142,7 @@ http://<host>.local:8080/
| POST | `/api/apps/install` | 经 `nyai install`(multipart 或 path) | ✅ |
| GET | `/api/apps/:pkg/export` | `nyai export` 下载流 | ✅ |
| DELETE | `/api/apps/:pkg` | `nyai remove [--purge]` | ✅ |
| POST | `/api/key/:code` | 按键注入(tap/long) | ✅(物理键红线内) |
| POST | `/api/key/:code` | 按键注入(tap/long;`ms` clamp 0..1000;电源 116 已下线) | ✅(物理键红线内) |
| POST | `/api/key` | 批量/顺序注入 | ✅ |
| GET | `/api/logs` | `MeowOS/logs` 文件列表(每文件含 `group` 来源分组) | ✅(无卡→503) |
| GET | `/api/logs/crash` | **崩溃汇总**:解析最新 `crash_<bootts>.log`(meow_crash 落盘格式)→ 每实体崩溃数 + 最近崩溃时间/信号 + dmesg 计数 | ✅(无卡→503;无 crash 日志→空汇总) |
@@ -218,8 +218,11 @@ http://<host>.local:8080/
### 5.5 nyai 对接
- 包管理器 CLI 绝对路径 `/usr/data/mnt/sd_0/MeowOS/opt/bin/nyai`(M2.12+ 固件经 `meow_card_path.sh` 前插 PATH,可直接调;
本后端用绝对路径,兼容更早固件)。包名 = `app.json` 的 `package` = `.mpk` 文件名茎。
- `install`:multipart 上传 `.mpk` 落临时文件(`MeowOS/tmp/`)或 JSON `{path}` → `nyai install <file>`;用 `popen` 捕获输出+退出码,
- `install`:multipart 上传 `.mpk` 落临时文件(`MeowOS/tmp/`)或 JSON `{path}` → `nyai install <file>`;用 `fork`+`execv`
传 argv 捕获输出+退出码(不经 shell,M64 P1-2:修复前是 `popen("... '<nyai>' install '<path>'")`,路径里一个 `'` 即 root 执行),
非 0 → 422。`export`:`nyai export <pkg> <tmp>` → 以 `Content-Disposition: attachment` 流式下发。`remove`:`nyai remove <pkg> [--purge]`。
- **参数校验**(M64 P1-2 / P2-7):包名统一 `mp_pkg_ok()`(仅 `[a-z0-9._-]`,拒 `..`/前导尾点,与 nyai.c `valid_pkg()` 同规则)
→ 非法 → 400;`{path}` 再按 `mp_path_unsafe()`(与 mp_fonts/nyai 同一拒绝集)挡一道,防调用被改回 shell 拼接。
- `GET /api/apps` / `:pkg` 详情:直接扫描 `MeowAPPs/<dir>/app.json`(手写 JSON 解析提取 name/version/exec/framework/immersive/fullscreen),
不依赖 nyai(nyai 未部署时列表/详情仍可用)。解析失败项 `invalid:true`。
- `nyai` 未部署 → install/export/remove 返回 500(带 nyai 输出)。
@@ -228,9 +231,15 @@ http://<host>.local:8080/
- **复用现有先例**:`patch_v2/uinput_tap/uinput_tap.c` 的 `write`-到-evdev 注入机制。launcher 实际轮询
`meow_launcher_internal.h`:`/dev/input/event0`(md-gpio-keys: POWER/PREVIOUS)、`/dev/input/event2`
(jz adc keyboard: VOL/PLAY/NEXT)。后端 open `O_WRONLY` 写 `EV_KEY press/release + SYN_REPORT` 即可到达 launcher 按键处理。
- **物理键红线**(APPS.md / 任务约束):只注入音量(114/115)/电源(116)/播放(164)/上下曲(163/165);
**禁止注入 返回(158)/主页(172)/确认(28)/退出(96)** → 403 `FORBIDDEN`。
- `tap`(默认,抬起间隔 80ms)、`long`(按住 ms,缺省 3000);`hold` → 501。
- **物理键红线**(APPS.md / 任务约束 + M64 P1-3):只注入音量(114/115)/播放(164)/上下曲(163/165);
**禁止注入 返回(158)/主页(172)/确认(28)/退出(96)/电源(116)** → 403 `FORBIDDEN`。
电源键(`/dev/input/event0` 的 KEY_POWER)已从 `keycode_to_device` 移除:设备端
`power.forceoff_ms` 缺省 8000ms 且**从按下起算**,注入它等于绕过 `/api/system/shutdown` 的二次确认
(一条 `{"press":"long","ms":8000}` 即强制关机)。电源/关机只走 `/api/system/shutdown`(前端有确认弹窗)。
- `tap`(默认,抬起间隔 80ms)、`long`(按住 ms,缺省 1000);`hold` → 501。
**`ms` 全局 clamp 到 0..1000**(`mp_key_clamp_ms`),单键与批量同一上限。
- 注入在**独立线程**里做按下→等待→抬起(`inject_key_async`):修复前是在请求线程 `usleep(ms)`,
一个键请求就把单线程 8080 服务整段阻塞。设备 fd 仍由请求线程同步 open,打不开 → 500。
- 局限:若 `/dev/input/event0/event2` 在设备上不可写(权限/路径差异),注入失败 → 500;注入为**即时性注入**,
不在 launcher 内维护长按状态机(与实体按键的 `longpressed` 语义近似,见 README 已知边界)。
+2 -1
View File
@@ -323,7 +323,7 @@ int files_list(int sock, const char *relpath);
char *read_text_file_limited(const char *abs, long long *size, int *truncated);
int keycode_to_device(int code, const char **dev);
int inject_key_event(const char *dev, int code, int down);
int inject_key_release(int code, int press_ms);
int inject_key_async(int code, int press_ms); /* M64 P1-3:释放放独立线程,不阻塞 panel */
int read_small_file(const char *path, char *buf, int cap);
int apps_scan_dir(const char *appdir, const char *name, sbuf_t *out, int want_detail);
void req_free(req_t *r);
@@ -436,6 +436,7 @@ int sha256_file_hex(const char *path, char out[65]);
int api_fonts_import(int sock, const req_t *r);
int api_fonts_export(int sock, const req_t *r);
int run_cmd(const char *cmd, char *out, int outcap);
int run_argv(char *out, int outcap, const char *const argv[]); /* M64 P1-2:execv 形态,无 shell */
int api_apps_list(int sock);
int api_apps_info(int sock, const char *pkg);
int multipart_extract_file(const req_t *r, const char *outpath);
+20 -12
View File
@@ -1,5 +1,6 @@
/* mp_apps.c — 应用域:MeowAPPs 扫描 + nyai 安装/导出/移除。 */
#include "mp.h"
#include "mp_argsafe.h" /* M64 P1-2:包名/路径判定(与 nyai.c:125/137 同规则) */
int apps_scan_dir(const char *appdir, const char *name, sbuf_t *out, int want_detail)
@@ -115,7 +116,8 @@ int api_apps_list(int sock)
int api_apps_info(int sock, const char *pkg)
{
if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡");
if (strchr(pkg, '/') || strchr(pkg, ':')) return send_err(sock, 400, "BAD_REQUEST", "包名非法");
/* M64 P1-2 / P2-7:旧判定只拒 `/` 与 `:`,`..` 与 `x';id;'` 都能过。 */
if (!mp_pkg_ok(pkg)) return send_err(sock, 400, "BAD_REQUEST", "包名非法");
char appdir[1024];
snprintf(appdir, sizeof appdir, "%s/%s", MEOWAPPS_DIR, pkg);
struct stat st;
@@ -164,10 +166,12 @@ int api_apps_install(int sock, const req_t *r)
snprintf(mpkpath, sizeof mpkpath, "%s", tmp);
}
if (access(mpkpath, R_OK) < 0) return send_err(sock, 404, "NOT_FOUND", ".mpk 文件不存在");
char cmd[1400];
snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin '%s' install '%s' 2>&1", nyai_bin(), mpkpath);
/* M64 P1-2:`{path}` 是外部可控串(旧实现直接塞进 popen 的 shell 行)。execv 传入后
* 元字符已无语义;这里仍按 nyai/mp_fonts 同一拒绝集挡住,防调用被改回 run_cmd。 */
if (mp_path_unsafe(mpkpath)) return send_err(sock, 400, "BAD_REQUEST", "路径含非法字符");
char out[4096];
int code = run_cmd(cmd, out, sizeof out);
const char *argv[] = { nyai_bin(), "install", mpkpath, NULL };
int code = run_argv(out, sizeof out, argv);
if (code != 0) return send_err(sock, 422, "UNSUPPORTED", out[0] ? out : "nyai install 失败");
/* 包名 = 文件名茎;版本从 app.json 读 */
char stem[256];
@@ -205,17 +209,16 @@ int api_apps_install(int sock, const req_t *r)
int api_apps_export(int sock, const char *pkg)
{
if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡");
if (strchr(pkg, '/') || strchr(pkg, ':')) return send_err(sock, 400, "BAD_REQUEST", "包名非法");
if (!mp_pkg_ok(pkg)) return send_err(sock, 400, "BAD_REQUEST", "包名非法");
char tmp[1100];
char card_tmp[1200];
snprintf(card_tmp, sizeof card_tmp, "%s/tmp", TF_CARD_DIR);
mkdir(card_tmp, 0777);
snprintf(tmp, sizeof tmp, "%s/tmp/%s.mpk", TF_CARD_DIR, pkg);
unlink(tmp);
char cmd[1400];
snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin '%s' export '%s' '%s' 2>&1", nyai_bin(), pkg, tmp);
char out[1024];
int code = run_cmd(cmd, out, sizeof out);
const char *argv[] = { nyai_bin(), "export", pkg, tmp, NULL };
int code = run_argv(out, sizeof out, argv);
if (code != 0 || access(tmp, R_OK) < 0) return send_err(sock, 500, "INTERNAL", out[0] ? out : "nyai export 失败");
char dn[256];
snprintf(dn, sizeof dn, "%s.mpk", pkg);
@@ -225,14 +228,19 @@ int api_apps_export(int sock, const char *pkg)
int api_apps_remove(int sock, const char *pkg, const req_t *r)
{
if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡");
if (strchr(pkg, '/') || strchr(pkg, ':')) return send_err(sock, 400, "BAD_REQUEST", "包名非法");
if (!mp_pkg_ok(pkg)) return send_err(sock, 400, "BAD_REQUEST", "包名非法");
char purgebuf[8] = "0";
if (r->query[0]) qparam(r->query, "purge", purgebuf, sizeof purgebuf);
int use_purge = atoi(purgebuf);
char cmd[1400];
snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin '%s' remove '%s'%s 2>&1", nyai_bin(), pkg, use_purge ? " --purge" : "");
char out[1024];
int code = run_cmd(cmd, out, sizeof out);
const char *argv[6];
int n = 0;
argv[n++] = nyai_bin();
argv[n++] = "remove";
argv[n++] = pkg;
if (use_purge) argv[n++] = "--purge";
argv[n] = NULL;
int code = run_argv(out, sizeof out, argv);
if (code != 0) return send_err(sock, 500, "INTERNAL", out[0] ? out : "nyai remove 失败");
sbuf_t s = {0};
sA(&s, "{\"removed\":true,\"purged\":%s}", use_purge ? "true" : "false");
+63
View File
@@ -0,0 +1,63 @@
/* mp_argsafe.h — MeowPanel 参数安全(M64 P1-2 / P1-3 纯函数,宿主可直接单测)。
*
* 只放「无副作用、不依赖 panel 运行态」的判定函数:这样 test_mp_argsafe.c 能把它和
* 反向控制(修复前的等价判定)跑在同一套用例上,证明校验真的拦住了东西。
*
* P1-2:App 安装/导出/卸载把外部串拼进 `PATH=... '<nyai>' install '<path>'` 再 popen。
* 修复:调用侧改 execv 传 argv(mp_util.c:run_argv),元字符失去 shell 语义;名字类
* 参数(包名)仍按结构拒绝——包名本就只有 [a-z0-9._-],没有兼容代价。
* P1-3:/api/key/<code> 的 press_ms 无上下限(一条 `{"press":"tap","ms":8000}` 即强制关机)。
* 修复:电源键从 keycode_to_device 移除 + press_ms 全局 clamp。
*/
#ifndef MP_ARGSAFE_H
#define MP_ARGSAFE_H
#include <stddef.h>
#include <string.h>
/* 包名合法:仅 [a-z0-9._-],拒绝 "." / ".." / 前导或尾随 '.'。
* 规则与 nyai.c:125 valid_pkg() 完全一致(两个二进制各自的私有实现,靠同一份规则收敛):
* 只收小写是安全要求——exFAT 名字比较不区分大小写,放过大写就能用 "OS.MEOW.READER"
* 落到已装的 MeowAPPs/os.meow.reader 上并绕过 is_system_pkg() 的签名门。
* 同时修掉 P2-7:旧判定只拒 `/` 与 `:`,`MeowAPPs/..` 与 `x';id;'` 都能过。 */
static inline int mp_pkg_ok(const char *s)
{
if (!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 (!((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
c == '.' || c == '-' || c == '_')) return 0;
}
return 1;
}
/* 路径/参数里不许出现的字符(拒绝集与 mp_fonts.c:22 unsafe_path、nyai.c:137 一致)。
* execv 已让它们不具备 shell 语义;这里保留为第二道,防后人把调用改回 popen/run_cmd。 */
static inline int mp_path_unsafe(const char *s)
{
return s && strpbrk(s, " ;`$'\"&|<>") != NULL;
}
/* /api/key/<code> 按压时长上限(ms)。设备端 power.forceoff_ms 缺省 8000ms、从按下起算,
* 修复前 ms 无上限 → 一条请求即强制关机(用户已被关机两次)。 */
#define MP_KEY_PRESS_MS_MAX 1000
static inline int mp_key_clamp_ms(int ms)
{
if (ms < 0) return 0;
if (ms > MP_KEY_PRESS_MS_MAX) return MP_KEY_PRESS_MS_MAX;
return ms;
}
/* 禁止注入的键码(APPS.md 物理键红线 + M64 P1-3 电源键)。
* 116=KEY_POWER:设备端强制关机计时从按下起算,注入它等于绕过 /api/system/shutdown 的
* 二次确认(用户已因此被关机两次)。修复前这里只有 158/172/32/28/96。 */
static inline int mp_key_forbidden(int code)
{
return code == 116 || code == 158 || code == 172 || code == 32 || code == 28 || code == 96;
}
#endif /* MP_ARGSAFE_H */
+67 -31
View File
@@ -1,14 +1,20 @@
/* mp_key.c — 远程控制域:按键注入(写 /dev/input/event* EV_KEY,物理键红线内)。 */
#include "mp.h"
#include "mp_argsafe.h" /* M64 P1-3:press_ms 上限 */
#include <pthread.h> /* 按键释放放独立线程:不再用 usleep 阻塞单线程 panel */
#define PWR_KEYDEV "/dev/input/event0"
#define ADC_KEYDEV "/dev/input/event2"
/* M64 P1-3:电源键(116)不再可注入。
* 修复前 KEY_POWER → /dev/input/event0,而设备端 power.forceoff_ms 缺省 8000ms 且**从按下
* 起算**——一条 `{"press":"long","ms":8000}` 即强制关机,绕过 /api/system/shutdown 的前端
* 二次确认(用户已因此被关机两次)。电源只走 /api/system/shutdown(带确认弹窗)。
* 前端 mp_pages.js 的遥控板电源/长按电源按钮已一并移除。 */
int keycode_to_device(int code, const char **dev)
{
switch (code) {
case KEY_POWER: *dev = PWR_KEYDEV; return KEY_POWER;
case KEY_PREVIOUSSONG: *dev = PWR_KEYDEV; return KEY_PREVIOUSSONG;
case KEY_VOLUMEUP: *dev = ADC_KEYDEV; return KEY_VOLUMEUP;
case KEY_VOLUMEDOWN: *dev = ADC_KEYDEV; return KEY_VOLUMEDOWN;
@@ -18,37 +24,63 @@ int keycode_to_device(int code, const char **dev)
}
}
int inject_key_event(const char *dev, int code, int down)
static int write_key_fd(int fd, int code, int down)
{
int fd = open(dev, O_WRONLY | O_NONBLOCK);
if (fd < 0) return -1;
struct input_event ev;
memset(&ev, 0, sizeof ev);
ev.type = EV_KEY;
ev.code = code;
ev.value = down ? 1 : 0;
int ok = (write(fd, &ev, sizeof ev) == (ssize_t)sizeof ev);
if (ok) {
memset(&ev, 0, sizeof ev);
ev.type = EV_SYN;
ev.code = SYN_REPORT;
ev.value = 0;
ok = (write(fd, &ev, sizeof ev) == (ssize_t)sizeof ev);
}
close(fd);
return ok ? 0 : -1;
if (write(fd, &ev, sizeof ev) != (ssize_t)sizeof ev) return -1;
memset(&ev, 0, sizeof ev);
ev.type = EV_SYN;
ev.code = SYN_REPORT;
ev.value = 0;
if (write(fd, &ev, sizeof ev) != (ssize_t)sizeof ev) return -1;
return 0;
}
int inject_key_release(int code, int press_ms)
int inject_key_event(const char *dev, int code, int down)
{
int fd = open(dev, O_WRONLY | O_NONBLOCK);
if (fd < 0) return -1;
int ok = write_key_fd(fd, code, down);
close(fd);
return ok;
}
typedef struct { int fd; int code; int press_ms; } keyjob_t;
/* 按下→等→松开在独立线程里做。修复前是请求线程里 usleep(press_ms)(还无上限):
* 一个键请求就把单线程 8080 服务整段阻塞住。press_ms 已 clamp 到 ≤1000ms,
* 线程是 drop 型(detach,自带 job 内存与 fd),请求线程立即返回。
* 设备 fd 仍由请求线程同步 open(失败立刻如实报 500,保住原有错误语义)。 */
static void *keyjob_run(void *arg)
{
keyjob_t *j = arg;
if (write_key_fd(j->fd, j->code, 1) == 0) {
usleep((useconds_t)(j->press_ms > 0 ? j->press_ms : 80) * 1000);
write_key_fd(j->fd, j->code, 0);
}
close(j->fd);
free(j);
return NULL;
}
int inject_key_async(int code, int press_ms)
{
const char *dev = NULL;
int kc = keycode_to_device(code, &dev);
if (kc < 0) return -1;
int release_ms = press_ms > 0 ? press_ms : 0;
if (inject_key_event(dev, code, 1) < 0) return -1;
if (release_ms > 0) usleep((useconds_t)release_ms * 1000);
else usleep(80 * 1000);
if (inject_key_event(dev, code, 0) < 0) return -1;
if (keycode_to_device(code, &dev) < 0) return -1;
int fd = open(dev, O_WRONLY | O_NONBLOCK);
if (fd < 0) return -1;
keyjob_t *j = malloc(sizeof *j);
if (!j) { close(fd); return -1; }
j->fd = fd;
j->code = code;
j->press_ms = mp_key_clamp_ms(press_ms);
pthread_t t;
if (pthread_create(&t, NULL, keyjob_run, j) != 0) { close(fd); free(j); return -1; }
pthread_detach(t);
return 0;
}
@@ -72,16 +104,19 @@ int api_key_single(int sock, int code, const req_t *r)
if (strcmp(press, "tap") && strcmp(press, "long"))
return send_err(sock, 400, "BAD_REQUEST", "press 非法");
if (press_ms <= 0) {
if (!strcmp(press, "long")) press_ms = 3000;
if (!strcmp(press, "long")) press_ms = MP_KEY_PRESS_MS_MAX;
}
/* 物理键红线:禁止映射 返回(158)/主页(172)/确认/exit(APPS.md)。 */
if (code == 158 || code == 172 || code == 32 || code == 28 || code == 96)
return send_err(sock, 403, "FORBIDDEN", "该键码(返回/主页/确认)按物理键红线禁止映射");
/* M64 P1-3:ms 无上下限时 `{"press":"long","ms":8000}` 直接强制关机(forceoff 从按下起算)。 */
press_ms = mp_key_clamp_ms(press_ms);
/* 物理键红线:禁止映射 返回(158)/主页(172)/确认/exit(APPS.md);电源(116) 已于 M64
* 从 keycode_to_device 移除,这里再显式拦一次,防将来有人把它加回去。 */
if (mp_key_forbidden(code))
return send_err(sock, 403, "FORBIDDEN", "该键码(电源/返回/主页/确认)禁止注入");
const char *tmpdev = NULL;
int dispatchable = (keycode_to_device(code, &tmpdev) >= 0);
if (!dispatchable)
return send_err(sock, 422, "UNSUPPORTED", "不支持的键码(仅音量/电源/播放/上下曲)");
if (inject_key_release(code, press_ms) < 0)
return send_err(sock, 422, "UNSUPPORTED", "不支持的键码(仅音量/播放/上下曲)");
if (inject_key_async(code, press_ms) < 0)
return send_err(sock, 500, "INTERNAL", "按键注入失败(无法打开输入设备)");
sbuf_t s = {0};
sA(&s, "{\"code\":%d,\"press\":\"%s\",\"dispatched\":true}", code, press);
@@ -106,12 +141,13 @@ int api_key_batch(int sock, const req_t *r)
jv_t *m = jv_obj_get(k, "ms");
if (!c || c->type != J_NUM) continue;
int code = (int)c->inum;
if (code == 158 || code == 172) continue; /* 红线跳过 */
if (mp_key_forbidden(code)) continue; /* 红线跳过(电源/返回/主页) */
int press_ms = (m && m->type == J_NUM) ? (int)m->inum : 0;
if (p && p->type == J_STR && !strcmp(p->str, "long") && press_ms <= 0) press_ms = 3000;
if (p && p->type == J_STR && !strcmp(p->str, "long") && press_ms <= 0) press_ms = MP_KEY_PRESS_MS_MAX;
press_ms = mp_key_clamp_ms(press_ms); /* M64 P1-3:与单键同一上限 */
const char *tmpdev = NULL;
if (keycode_to_device(code, &tmpdev) < 0) continue;
if (inject_key_release(code, press_ms) < 0) continue;
if (inject_key_async(code, press_ms) < 0) continue;
if (!first) sA(&s, ",");
first = 0;
sA(&s, "%d", code);
+40
View File
@@ -360,6 +360,46 @@ int run_cmd(const char *cmd, char *out, int outcap)
return code;
}
/* 执行子进程(execv 传 argv,不经过 shell)并收 stdout+stderr;返回退出码(-1=起不来)。
* M64 P1-2:外部可控串(mpk 路径、包名)只走这条——修复前是拼 `PATH=... '<nyai>' install
* '<path>'` 再 popen,路径里一个 `'` 就是 root 命令执行。语义对齐 run_cmd(输出有界截断)。 */
int run_argv(char *out, int outcap, const char *const argv[])
{
if (outcap <= 0 || !out || !argv || !argv[0]) return -1;
out[0] = 0;
int pfd[2];
if (pipe(pfd) < 0) return -1;
pid_t pid = fork();
if (pid < 0) { close(pfd[0]); close(pfd[1]); return -1; }
if (pid == 0) {
dup2(pfd[1], 1);
dup2(pfd[1], 2);
close(pfd[0]); close(pfd[1]);
/* nyai 内部还要找 busybox/tar:PATH 在子进程里设,仍然不经过 shell。 */
setenv("PATH", "/bin:/sbin:/usr/bin:/usr/sbin", 1);
execv(argv[0], (char *const *)argv);
_exit(127);
}
close(pfd[1]);
size_t off = 0;
char discard[256];
for (;;) {
char *dst;
size_t room;
if (off + 1 < (size_t)outcap) { dst = out + off; room = (size_t)outcap - 1 - off; }
else { dst = discard; room = sizeof discard; }
ssize_t rd = read(pfd[0], dst, room);
if (rd <= 0) break;
if (dst != discard) off += (size_t)rd;
}
close(pfd[0]);
out[off] = 0;
int st = 0;
if (waitpid(pid, &st, 0) < 0) return -1;
if (WIFEXITED(st)) return WEXITSTATUS(st);
return -1;
}
int64_t now_mono_ms(void)
{
struct timespec ts;
+115
View File
@@ -0,0 +1,115 @@
/* test_mp_argsafe.c — M64 P1-2 / P1-3 宿主回归:App 安装路径与包名、按键时长与键码红线。
*
* 纯宿主测试:不连设备、不启面板服务、不发 /api/key/<code> 请求。
*
* 编译运行(在 patch_v2/meowpanel 下):
* cc -O1 -Wall -Wextra -I src test_mp_argsafe.c -o ~/tmp/test_mp_argsafe
* ~/tmp/test_mp_argsafe
*
* 反向控制:同一套用例分别喂给
* ① 「修复前实现」复刻(legacy_pkg_accepts = mp_apps.c:228 旧判定;legacy_path_build =
* mp_apps.c:168 旧拼后再 popen)→ 必须放行/注入成功;
* ② 修复后实现(mp_pkg_ok/mp_path_unsafe/mp_key_clamp_ms/mp_key_forbidden)→ 必须拒绝。
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include "mp_argsafe.h"
static int g_fail;
#define CHECK(cond, ...) do { \
if (!(cond)) { printf(" FAIL: "); printf(__VA_ARGS__); printf("\n"); g_fail = 1; } \
else { printf(" PASS: "); printf(__VA_ARGS__); printf("\n"); } \
} while (0)
/* 修复前 mp_apps.c:118/208/228 的包名判定:只拒 `/` 与 `:`。 */
static int legacy_pkg_accepts(const char *pkg)
{
return pkg && !strchr(pkg, '/') && !strchr(pkg, ':');
}
/* 修复前 mp_apps.c:168 的拼法 + mp_util.c:run_cmd 的 popen。 */
static void legacy_path_build(const char *mpk, char *out, size_t cap)
{
snprintf(out, cap, "PATH=/bin:/sbin:/usr/bin:/usr/sbin '%s' install '%s' 2>&1",
"/usr/bin/nyai", mpk);
}
static int run_shell(const char *cmd, char *out, int cap)
{
out[0] = 0;
FILE *f = popen(cmd, "r");
if (!f) return -1;
size_t off = fread(out, 1, (size_t)cap - 1, f);
out[off] = 0;
return pclose(f);
}
int main(void)
{
printf("== M64 P1-2 反向控制:包名 ==================================================\n");
/* 审计给出的真实恶意串。 */
static const char *bad_pkgs[] = { "x';id;'", "MeowAPPs/..", "..", ".", "..foo", "foo.",
"OS.MEOW.READER", "os.meow.reader/../x", "a b", "" };
for (unsigned i = 0; i < sizeof bad_pkgs / sizeof bad_pkgs[0]; i++) {
const char *p = bad_pkgs[i];
int old_acc = legacy_pkg_accepts(p);
int new_acc = mp_pkg_ok(p);
CHECK(!new_acc, "拒绝包名 `%s`(修复前:%s)", p, old_acc ? "放行 → 可注入" : "也拒");
}
/* 反向控制的正向对照:旧判定确实放行了这两条。 */
CHECK(legacy_pkg_accepts("x';id;'") == 1, "反向控制:修复前放行 `x';id;'`(DELETE /api/apps/x';id;')");
/* P2-7 的穿越面是裸 `..`(拼接后 = MEOWAPPS_DIR/.. );`MeowAPPs/..` 含 `/` 反而被旧判定挡住。 */
CHECK(legacy_pkg_accepts("..") == 1, "反向控制:修复前放行裸 `..`(MEOWAPPS_DIR/.. 目录穿越)");
static const char *good_pkgs[] = { "os.meow.reader", "os.meow.app-1", "a_b-c.d", "reader" };
for (unsigned i = 0; i < sizeof good_pkgs / sizeof good_pkgs[0]; i++)
CHECK(mp_pkg_ok(good_pkgs[i]) == 1, "接受合法包名 `%s`", good_pkgs[i]);
printf("== M64 P1-2 反向控制:安装路径 ==============================================\n");
{
/* 审计原串(含 `>` 重定向,写文件的那半句在宿主上不执行——只做字符串判定)。 */
const char *audit = "/tmp/a';id>/tmp/pwn;'.mpk";
CHECK(mp_path_unsafe(audit) == 1, "拒绝审计原路径 `%s`", audit);
CHECK(mp_path_unsafe("/usr/data/mnt/sd_0/MeowOS/tmp/upload.mpk") == 0,
"接受面板自己生成的上传临时路径");
CHECK(mp_path_unsafe("/sdcard/MeowApps/reader-v1.mpk") == 0, "接受普通路径");
/* 执行级证明:结构同审计串、但注入体换成 `id`(不写任何文件)。
* ① 修复前:值进 shell → `id` 执行。 */
const char *pwn = "/tmp/a';id;'.mpk";
char cmd[512], out[4096];
legacy_path_build(pwn, cmd, sizeof cmd);
printf(" 修复前命令:%s\n", cmd);
run_shell(cmd, out, sizeof out);
CHECK(strstr(out, "uid=") != NULL, "修复前被注入:路径里的 `id` 执行(stdout 含 uid=)");
/* ② 修复后:同一串被 mp_path_unsafe 拒掉,且永远不会到达 shell。 */
CHECK(mp_path_unsafe(pwn) == 1, "修复后同一串在调用 execv 之前就被拒");
}
printf("== M64 P1-3:press_ms clamp ================================================\n");
CHECK(mp_key_clamp_ms(-5) == 0, "-5 → 0");
CHECK(mp_key_clamp_ms(0) == 0, "0 → 0(注入侧按 80ms tap 节奏处理)");
CHECK(mp_key_clamp_ms(80) == 80, "80 → 80");
CHECK(mp_key_clamp_ms(1000) == 1000, "1000 → 1000(上限)");
CHECK(mp_key_clamp_ms(3000) == 1000, "3000 → 1000(前端旧 `long` 默认值被收口)");
CHECK(mp_key_clamp_ms(8000) == 1000, "8000 → 1000(设备端 forceoff_ms 缺省值:一条请求即强制关机)");
CHECK(mp_key_clamp_ms(2147483647) == 1000, "INT_MAX → 1000");
printf("== M64 P1-3:键码红线 ======================================================\n");
/* 修复前 mp_key.c:78 只拦 158/172/32/28/96。 */
static const int codes[] = { 116, 158, 172, 32, 28, 96 };
for (unsigned i = 0; i < sizeof codes / sizeof codes[0]; i++)
CHECK(mp_key_forbidden(codes[i]) == 1, "禁止键码 %d", codes[i]);
CHECK(116 == 116 && !(116 == 158 || 116 == 172 || 116 == 32 || 116 == 28 || 116 == 96),
"反向控制:修复前的红线**放行** 116(KEY_POWER)");
static const int allowed[] = { 114, 115, 163, 164, 165 };
for (unsigned i = 0; i < sizeof allowed / sizeof allowed[0]; i++)
CHECK(mp_key_forbidden(allowed[i]) == 0, "允许键码 %d(音量/上下曲/播放)", allowed[i]);
printf(g_fail ? "\nRESULT: FAIL\n" : "\nRESULT: ALL PASS\n");
return g_fail ? 1 : 0;
}
+2 -5
View File
@@ -2293,7 +2293,6 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
h += remoteBtn('play', 'play', '播放/暂停', '媒体播放/暂停切换(code=164)');
h += remoteBtn('next', 'next', '下一曲', '下一曲(code=163)');
h += remoteBtn('prev', 'prev', '上一曲', '上一曲(code=165)');
h += remoteBtn('power', 'power', '电源', '电源键(单击;code=116)');
h += remoteBtn('back', 'back', '返回', '左缘右滑返回(注入设备已识别的返回手势)');
h += remoteBtn('home', 'home', '回桌面', '底部上滑回桌面(注入设备已识别的回桌面手势)');
h += '</div>';
@@ -2302,9 +2301,9 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
'<input type="range" id="ctl-bright" min="' + ctlBright.min + '" max="' + ctlBright.max + '" value="' + ctlBright.value + '">' +
'<span class="muted" id="ctl-bright-val" style="min-width:40px;text-align:right">' + ctlBright.value + '</span>' +
'</div>';
h += '<button class="btn" id="ctl-powerlong">' + (MP_ICONS.power || '') + ' <span>长按电源(电源菜单)</span></button>';
h += '<p class="muted" style="margin-top:8px;line-height:1.8">滑动手势(在遥控板上):上滑=音量+ · 下滑=音量- · 左滑=下一曲 · 右滑=上一曲 · 左缘右滑=返回。<br>' +
'<b>返回/回桌面</b>走手势注入(<code>/api/input/gesture</code>,模拟设备已识别的左缘右滑/底部上滑),<b>不走</b>被物理键红线禁注入的 <code>/api/key</code> 158/172。</p>';
'<b>返回/回桌面</b>走手势注入(<code>/api/input/gesture</code>,模拟设备已识别的左缘右滑/底部上滑),<b>不走</b>被物理键红线禁注入的 <code>/api/key</code> 158/172。<br>' +
'<b>电源/关机</b>不走按键注入(<code>/api/key/116</code> 已下线:设备端强制关机计时从按下起算,一条请求即关机)——请到「系统」页用<b>关机</b>(带二次确认)。</p>';
h += '</div>';
return h;
}
@@ -2769,12 +2768,10 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
else if (a === 'play') sendKey(164, 'tap');
else if (a === 'next') sendKey(163, 'tap');
else if (a === 'prev') sendKey(165, 'tap');
else if (a === 'power') sendKey(116, 'tap');
else if (a === 'back') sendGesture('back');
else if (a === 'home') sendGesture('home');
return;
}
if (e.target.closest('#ctl-powerlong')) { sendKey(116, 'long', 3000); return; }
if (e.target.closest('#screen-img') || e.target.closest('#screen-canvas')) { screenTap(e); return; }
});
// 滑动手势(仅遥控板);屏幕区点击=注入,不参与手势