Files
knutwurst--ps5-patchdl/src/patchdl_scan.c
T
Knutwurst 5f0d1be078 Harden filesystem safety after a security review
Defense in depth around the only operations that touch the filesystem,
so no request can escape /data/patchdl or leave the process able to
write to a system path:

- Validate the HTTP title_id with path_segment_safe at the top of the
  title-action route, and again inside remove_title_dir and
  cleanup_installed_download. The delete primitives are now self-
  protecting instead of relying only on the "title exists in the scan"
  guard, so a future refactor cannot reintroduce a /data-wiping
  traversal (a title_id of ".." would otherwise resolve the dir to
  /data).
- Only swap the process root vnode when the current root was captured
  and can be restored, in both the scan and the debug dump. Otherwise
  the process could be left rooted at the system root, sending later
  absolute-path writes to the wrong place.

Reviewed and confirmed safe with no change needed: the installer only
delegates to Sony's signed AppInstUtil service (it never writes or
redirects to system paths itself), app.db is opened read-only and
immutable, every write targets /data/patchdl, the patch picker never
selects an update that needs a newer firmware, and the /api/pkg file
server already blocks path traversal.
2026-06-23 19:46:23 +02:00

534 lines
17 KiB
C

#include "patchdl_scan.h"
#include "patchdl_appdb.h"
#include <ps5/kernel.h>
#include <dirent.h>
#include <limits.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mount.h>
#include <sys/stat.h>
#include <unistd.h>
#define MAX_TITLES 512
static const char *SCAN_DIRS[] = {
"/user/app",
"/mnt/ext0/user/app",
"/mnt/ext1/user/app",
"/system_ex/app",
NULL,
};
/* ---------- param.json -------------------------------------------------- */
static int
json_str(const char *json, const char *key, char *out, size_t out_sz) {
char needle[80];
const char *p;
size_t len;
if ((size_t)snprintf(needle, sizeof(needle), "\"%s\"", key) >= sizeof(needle))
return -1;
p = strstr(json, needle);
if (!p) return -1;
p += strlen(needle);
while (*p == ' ' || *p == '\t' || *p == ':' || *p == '\n' || *p == '\r')
p++;
if (*p != '"') return -1;
p++;
for (len = 0; p[len] && p[len] != '"' && len < out_sz - 1; len++)
;
memcpy(out, p, len);
out[len] = '\0';
return 0;
}
/* Extract the title name for the default language out of the nested
localizedParameters block. PS5 param.json has no flat titleName key. */
static void
json_title_name(const char *json, char *out, size_t out_sz) {
char lang[16] = {0};
const char *p;
if (!json_str(json, "defaultLanguage", lang, sizeof(lang)) && lang[0]) {
char anchor[24];
if ((size_t)snprintf(anchor, sizeof(anchor), "\"%s\"", lang) <
sizeof(anchor)) {
p = strstr(json, anchor);
if (p) {
const char *tn = strstr(p, "\"titleName\"");
if (tn && !json_str(tn, "titleName", out, out_sz))
return;
}
}
}
/* Fallback: first titleName anywhere in the file */
json_str(json, "titleName", out, out_sz);
}
static int
try_param_json(const char *dir, patchdl_title_t *t) {
char path[PATH_MAX];
FILE *fp;
char buf[8192];
size_t n;
snprintf(path, sizeof(path), "%s/sce_sys/param.json", dir);
fp = fopen(path, "r");
if (!fp) return -1;
n = fread(buf, 1, sizeof(buf) - 1, fp);
fclose(fp);
if (!n) return -1;
buf[n] = '\0';
/* PS5 keys first, then PS4 fallbacks */
if (json_str(buf, "titleId", t->title_id, sizeof(t->title_id)) &&
json_str(buf, "TITLE_ID", t->title_id, sizeof(t->title_id)))
return -1;
json_str(buf, "contentId", t->content_id, sizeof(t->content_id));
json_str(buf, "contentVersion", t->installed_version, sizeof(t->installed_version));
if (!t->content_id[0])
json_str(buf, "CONTENT_ID", t->content_id, sizeof(t->content_id));
if (!t->installed_version[0])
json_str(buf, "APP_VER", t->installed_version, sizeof(t->installed_version));
json_title_name(buf, t->name, sizeof(t->name));
return 0;
}
/* ---------- param.sfo --------------------------------------------------- */
#pragma pack(push, 1)
typedef struct {
uint32_t magic; /* 0x46535000 = "\x00PSF" */
uint32_t version;
uint32_t key_table_start;
uint32_t data_table_start;
uint32_t num_entries;
} sfo_hdr_t;
typedef struct {
uint16_t key_offset;
uint16_t data_fmt;
uint32_t data_len;
uint32_t data_max_len;
uint32_t data_offset;
} sfo_entry_t;
#pragma pack(pop)
#define SFO_MAGIC 0x46535000u
#define SFO_FMT_STR 0x0204u
static int
sfo_get(const uint8_t *buf, size_t bufsz, const char *key,
char *out, size_t out_sz) {
const sfo_hdr_t *h;
const sfo_entry_t *entries;
uint32_t i;
if (bufsz < sizeof(*h)) return -1;
h = (const sfo_hdr_t *)buf;
if (h->magic != SFO_MAGIC || h->num_entries > 256) return -1;
entries = (const sfo_entry_t *)(buf + sizeof(*h));
/* The entry table itself must fit in the bytes we actually read; a crafted
param.sfo (from a shadow-mounted game dir) could otherwise drive
entries[i] past the buffer. */
if (sizeof(*h) + (size_t)h->num_entries * sizeof(sfo_entry_t) > bufsz)
return -1;
for (i = 0; i < h->num_entries; i++) {
size_t key_off = h->key_table_start + entries[i].key_offset;
size_t val_off = h->data_table_start + entries[i].data_offset;
if (key_off >= bufsz || val_off >= bufsz) continue;
const char *k = (const char *)(buf + key_off);
size_t kmax = bufsz - key_off;
/* Require the key to be NUL-terminated within the buffer before the
strcmp, otherwise it would read past the end. */
if (strnlen(k, kmax) == kmax) continue;
if (strcmp(k, key)) continue;
if (entries[i].data_fmt != SFO_FMT_STR) return -1;
size_t len = entries[i].data_len;
if (val_off + len > bufsz) return -1;
if (len >= out_sz) len = out_sz - 1;
memcpy(out, buf + val_off, len);
out[len] = '\0';
return 0;
}
return -1;
}
static int
try_param_sfo(const char *dir, patchdl_title_t *t) {
char path[PATH_MAX];
FILE *fp;
uint8_t buf[8192];
size_t n;
struct stat st;
snprintf(path, sizeof(path), "%s/sce_sys/param.sfo", dir);
fp = fopen(path, "rb");
if (!fp) return -1;
if (fstat(fileno(fp), &st) || (size_t)st.st_size > sizeof(buf)) {
fclose(fp);
return -1;
}
n = fread(buf, 1, (size_t)st.st_size, fp);
fclose(fp);
if (n != (size_t)st.st_size) return -1;
if (sfo_get(buf, n, "TITLE_ID", t->title_id, sizeof(t->title_id)))
return -1;
sfo_get(buf, n, "CONTENT_ID", t->content_id, sizeof(t->content_id));
sfo_get(buf, n, "APP_VER", t->installed_version, sizeof(t->installed_version));
sfo_get(buf, n, "TITLE", t->name, sizeof(t->name));
if (!t->name[0])
sfo_get(buf, n, "TITLE_00", t->name, sizeof(t->name));
return 0;
}
/* ---------- install-type classification (file-based) -------------------- */
static int
path_exists(const char *dir, const char *rel) {
char p[PATH_MAX];
struct stat st;
snprintf(p, sizeof(p), "%s/%s", dir, rel);
return stat(p, &st) == 0;
}
/* Read up to sz-1 bytes of dir/rel into buf (NUL-terminated). 0 on success. */
static int
read_small_file(const char *dir, const char *rel, char *buf, size_t sz) {
char p[PATH_MAX];
FILE *fp;
size_t n;
snprintf(p, sizeof(p), "%s/%s", dir, rel);
fp = fopen(p, "rb");
if (!fp) return -1;
n = fread(buf, 1, sz - 1, fp);
fclose(fp);
buf[n] = '\0';
return 0;
}
/* Keep only actual games. Skip PS5 system apps (NPXS), the Payload Manager
fake app (PLDM) and ELF-loader homebrew (PSPS). */
static int
is_game_title(const char *title_id) {
if (!strncmp(title_id, "NPXS", 4)) return 0;
if (!strncmp(title_id, "PLDM", 4)) return 0;
if (!strncmp(title_id, "PSPS", 4)) return 0;
return 1;
}
/* ---------- directory scanner ------------------------------------------- */
/* Authoritative shadowmount test: ShadowMountPlus routes its images through
/mnt/shadowmnt (a pfs from /dev/lvdN there, then a nullfs onto the app dir),
so a title whose mount table references /mnt/shadowmnt is a shadowmount. The
on-disk mount.lnk marker is unreliable across reboots/remounts; the live
mount table is not. */
static int
mount_is_shadow(const char *title_id, const struct statfs *mounts, int nmounts) {
for (int i = 0; i < nmounts; i++) {
const char *from = mounts[i].f_mntfromname;
const char *on = mounts[i].f_mntonname;
if ((strstr(from, "/mnt/shadowmnt") || strstr(on, "/mnt/shadowmnt")) &&
(strstr(from, title_id) || strstr(on, title_id)))
return 1;
}
return 0;
}
/*
* Classify each /user/app/<TID> (verified on fw 11.60):
* - mount table references /mnt/shadowmnt for the title -> ShadowMountPlus
* mount (authoritative; mount.lnk is only a fallback hint)
* - app.pkg (no shadow mount) -> genuine install; app.json with CDN piece
* URLs means a not-downloaded preinstall stub, local URLs a real install
* - app.json with "fake":true -> homebrew fake (skip)
*/
static int
scan_one(const char *base, const char *name, patchdl_title_t *t,
const struct statfs *mounts, int nmounts) {
char dir[PATH_MAX];
char appjson[4096];
int has_mountlnk, has_app_pkg, has_paramjson, is_shadow, is_fake = 0, is_cdn = 0;
snprintf(dir, sizeof(dir), "%s/%s", base, name);
memset(t, 0, sizeof(*t));
strncpy(t->source_path, dir, sizeof(t->source_path) - 1);
strncpy(t->title_id, name, sizeof(t->title_id) - 1);
if (!is_game_title(t->title_id))
return -1;
is_shadow = mount_is_shadow(t->title_id, mounts, nmounts);
has_mountlnk = path_exists(dir, "mount.lnk") ||
path_exists(dir, "mount_img.lnk");
has_app_pkg = path_exists(dir, "app.pkg");
has_paramjson = path_exists(dir, "sce_sys/param.json");
if (!read_small_file(dir, "app.json", appjson, sizeof(appjson))) {
if (strstr(appjson, "\"fake\"") && strstr(appjson, "true"))
is_fake = 1;
if (strstr(appjson, "http"))
is_cdn = 1; /* piece URLs point at the Sony CDN -> preinstall */
}
if (is_fake)
return -1;
/* app.pkg is the on-disk package of a genuine install; ShadowMountPlus
titles never have it (they have mounted/leftover sce_sys content). So
app.pkg is the reliable genuine-vs-shadow discriminator — independent of
whether the shadow image is currently mounted. */
if (has_app_pkg) {
t->source_type = is_cdn ? PATCHDL_SOURCE_UNKNOWN /* CDN pkg = preinstall */
: PATCHDL_SOURCE_OFFICIAL;
} else if (is_shadow || has_mountlnk || has_paramjson ||
path_exists(dir, "sce_sys/param.sfo")) {
if (try_param_json(dir, t)) /* metadata from the mounted sce_sys */
try_param_sfo(dir, t);
t->source_type = PATCHDL_SOURCE_SHADOWMOUNT;
} else {
return -1; /* empty / leftover directory */
}
if (!t->title_id[0])
strncpy(t->title_id, name, sizeof(t->title_id) - 1);
if (!t->name[0])
strncpy(t->name, t->title_id, sizeof(t->name) - 1);
return 0;
}
static int
already_seen(const patchdl_title_t *arr, size_t cnt, const char *title_id) {
for (size_t i = 0; i < cnt; i++)
if (!strcmp(arr[i].title_id, title_id))
return 1;
return 0;
}
static void
scan_base(const char *base, patchdl_title_t *arr, size_t *cnt, size_t cap,
const struct statfs *mounts, int nmounts) {
DIR *d;
struct dirent *de;
d = opendir(base);
if (!d) return;
while ((de = readdir(d))) {
if (de->d_name[0] == '.') continue;
if (*cnt >= cap) break;
if (scan_one(base, de->d_name, &arr[*cnt], mounts, nmounts) != 0)
continue;
if (already_seen(arr, *cnt, arr[*cnt].title_id))
continue; /* dedupe a title already found in an earlier base */
(*cnt)++;
}
closedir(d);
}
/* ---------- public API -------------------------------------------------- */
const char *
patchdl_source_str(patchdl_source_t src) {
switch (src) {
case PATCHDL_SOURCE_OFFICIAL: return "official";
case PATCHDL_SOURCE_EXTERNAL: return "external";
case PATCHDL_SOURCE_SHADOWMOUNT: return "shadowmount";
default: return "unknown";
}
}
/* Overlay app.db metadata (real title name, installed version, version.xml
URL) onto the scanned titles. Genuine installs have no readable param.json,
so this is the only source of their name/version. */
static void
merge_appdb(patchdl_title_t *arr, size_t cnt) {
patchdl_appinfo_t *info = NULL;
size_t n = 0;
if (patchdl_appdb_load(&info, &n))
return;
for (size_t i = 0; i < cnt; i++) {
const patchdl_appinfo_t *a = patchdl_appdb_find(info, n, arr[i].title_id);
if (!a) continue;
if (a->title_name[0])
strncpy(arr[i].name, a->title_name, sizeof(arr[i].name) - 1);
if (a->content_version[0])
strncpy(arr[i].installed_version, a->content_version,
sizeof(arr[i].installed_version) - 1);
if (a->content_id[0] && !arr[i].content_id[0])
strncpy(arr[i].content_id, a->content_id,
sizeof(arr[i].content_id) - 1);
if (a->version_file_uri[0])
strncpy(arr[i].version_file_uri, a->version_file_uri,
sizeof(arr[i].version_file_uri) - 1);
}
patchdl_appdb_free(info);
}
int
patchdl_scan(patchdl_title_t **titles_out, size_t *count_out) {
patchdl_title_t *arr;
size_t cnt = 0;
pid_t pid = getpid();
intptr_t saved_root = 0, root_vnode;
int using_vswap = 0;
struct statfs *mounts = NULL;
int nmounts;
arr = calloc(MAX_TITLES, sizeof(*arr));
if (!arr) return -1;
/* Elevate so privileged paths (the app.db under /system_data) are
readable; same authid ftpsrv uses. No-op without kernel R/W. */
kernel_set_ucred_authid(pid, 0x4801000000000013L);
/* Global mount table for shadowmount detection. getmntinfo's buffer is
libc-managed — must NOT be freed. */
nmounts = getmntinfo(&mounts, MNT_NOWAIT);
if (nmounts < 0) { nmounts = 0; mounts = NULL; }
root_vnode = kernel_get_root_vnode();
if (root_vnode) {
saved_root = kernel_get_proc_rootdir(pid);
/* Only swap if we captured the current root, so we can always restore
it. Leaving the process rooted at the system root would make later
absolute-path writes land in the wrong place. */
if (saved_root) {
kernel_set_proc_rootdir(pid, root_vnode);
using_vswap = 1;
}
}
for (int i = 0; SCAN_DIRS[i]; i++)
scan_base(SCAN_DIRS[i], arr, &cnt, MAX_TITLES, mounts, nmounts);
merge_appdb(arr, cnt);
if (using_vswap)
kernel_set_proc_rootdir(pid, saved_root);
*titles_out = arr;
*count_out = cnt;
return 0;
}
void
patchdl_scan_free(patchdl_title_t *titles, size_t count) {
(void)count;
free(titles);
}
/* ---------- diagnostic dump --------------------------------------------- */
static char *
dbg_append(char *buf, size_t *len, size_t *cap, const char *s) {
size_t sl = strlen(s);
if (*len + sl + 1 > *cap) {
size_t nc = (*cap + sl + 1) * 2;
char *p = realloc(buf, nc);
if (!p) return buf;
buf = p;
*cap = nc;
}
memcpy(buf + *len, s, sl + 1);
*len += sl;
return buf;
}
char *
patchdl_scan_debug_json(void) {
char *buf = NULL;
size_t len = 0, cap = 0;
char tmp[2048];
pid_t pid = getpid();
intptr_t saved_root = 0, root_vnode;
int using_vswap = 0;
struct statfs *mounts = NULL;
int nmounts;
/* getmntinfo returns the global mount table — no root-vnode swap needed.
Must NOT free the returned buffer (libc-managed). */
nmounts = getmntinfo(&mounts, MNT_NOWAIT);
if (nmounts < 0) { nmounts = 0; mounts = NULL; }
buf = dbg_append(buf, &len, &cap, "{\"mounts\":[");
for (int i = 0; i < nmounts; i++) {
snprintf(tmp, sizeof(tmp),
"%s{\"from\":\"%s\",\"on\":\"%s\",\"type\":\"%s\",\"flags\":\"0x%lx\"}",
i ? "," : "",
mounts[i].f_mntfromname, mounts[i].f_mntonname,
mounts[i].f_fstypename, (unsigned long)mounts[i].f_flags);
buf = dbg_append(buf, &len, &cap, tmp);
}
buf = dbg_append(buf, &len, &cap, "],\"bases\":[");
root_vnode = kernel_get_root_vnode();
if (root_vnode) {
saved_root = kernel_get_proc_rootdir(pid);
/* Only swap if we can restore it afterwards (see patchdl_scan). */
if (saved_root) {
kernel_set_proc_rootdir(pid, root_vnode);
using_vswap = 1;
}
}
for (int i = 0; SCAN_DIRS[i]; i++) {
DIR *d = opendir(SCAN_DIRS[i]);
snprintf(tmp, sizeof(tmp), "%s{\"base\":\"%s\",\"exists\":%s,\"entries\":[",
i ? "," : "", SCAN_DIRS[i], d ? "true" : "false");
buf = dbg_append(buf, &len, &cap, tmp);
if (d) {
struct dirent *de;
int first = 1;
while ((de = readdir(d))) {
char dir[PATH_MAX];
if (de->d_name[0] == '.') continue;
snprintf(dir, sizeof(dir), "%s/%s", SCAN_DIRS[i], de->d_name);
snprintf(tmp, sizeof(tmp),
"%s{\"name\":\"%s\",\"pj\":%s,\"pkg\":%s,\"lnk\":%s}",
first ? "" : ",", de->d_name,
path_exists(dir, "sce_sys/param.json") ? "true" : "false",
path_exists(dir, "app.pkg") ? "true" : "false",
(path_exists(dir, "mount.lnk") ||
path_exists(dir, "mount_img.lnk")) ? "true" : "false");
buf = dbg_append(buf, &len, &cap, tmp);
first = 0;
}
closedir(d);
}
buf = dbg_append(buf, &len, &cap, "]}");
}
if (using_vswap)
kernel_set_proc_rootdir(pid, saved_root);
buf = dbg_append(buf, &len, &cap, "]}");
return buf;
}