From 5ba72b850c1b07e02cb609640e42969ca8495dbf Mon Sep 17 00:00:00 2001 From: Knutwurst <36196269+knutwurst@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:29:24 +0200 Subject: [PATCH] Net: scope JSON parser, cap manifest, harden DNS/CURL allowlist Manifest JSON parser used substring scans with no per-piece scope; a key defined in a later piece could be misattributed to the current one, and the escape handling silently dropped the byte after a backslash even for unknown escapes. json_string_after/json_u64_after now take an optional limit pointer (NULL = legacy unbounded), and the manifest loops pass obj_end so per-piece reads can't leak across pieces. JSON escapes are decoded properly: \" \\ \/ \n \r \t \b \f; unknown \X drops the backslash and keeps the payload byte. Sanity caps on assembled manifests: PATCHDL_MAX_PIECES (4096), PATCHDL_MAX_PIECE_BYTES (8 GiB), PATCHDL_MAX_TOTAL_BYTES (200 GiB). A malformed manifest with a single multi-TB piece or millions of entries is now rejected before any disk activity. patchdl_buf_t gains an optional `max` field; write_cb fails the transfer when growth would exceed it. patchdl_http_get preserves the caller-set max across its internal memset(). verxml_query caps at 16 MiB, fetch_manifest and download_manifest at 64 MiB. host_allowed switches to strcasecmp (DNS is case-insensitive; an upstream redirect could otherwise drop out of the list). CURLOPT_PROTOCOLS_STR / REDIR_PROTOCOLS_STR pin all traffic and redirects to HTTPS. CURLOPT_NOSIGNAL=1 prevents libcurl from raising SIGPIPE in a worker. DNS label parser bounds-checks the length byte before incrementing pos, so a malformed response with a 0xFF label near the end can no longer read past the receive buffer. extract_title_id used `p[8]` as the loop guard, which crossed the NUL terminator on strings shorter than 9 chars (UB). Replaced with a strlen-based bound. --- src/patchdl_net.c | 150 +++++++++++++++++++++++++++++++++++-------- src/patchdl_net.h | 1 + src/patchdl_verxml.c | 20 ++++-- 3 files changed, 138 insertions(+), 33 deletions(-) diff --git a/src/patchdl_net.c b/src/patchdl_net.c index ccbe4e9..9009132 100644 --- a/src/patchdl_net.c +++ b/src/patchdl_net.c @@ -22,6 +22,17 @@ #define DNS_PORT 53 #define DNS_TIMEOUT_MS 3000 +/* Manifest sanity caps — reject anything bigger than a real PS5 patch. The + largest title we've seen tops out around 70 GB / 18 pieces. */ +#define PATCHDL_MAX_PIECES 4096 +#define PATCHDL_MAX_PIECE_BYTES (8ULL * 1024 * 1024 * 1024) /* 8 GiB */ +#define PATCHDL_MAX_TOTAL_BYTES (200ULL * 1024 * 1024 * 1024) /* 200 GiB */ + +/* In-RAM buffer caps for full HTTP body fetches. version.xml is a few KB, + manifest JSON is a few MB at most — fail-closed beyond that. */ +#define PATCHDL_BUF_MAX_VERXML (16 * 1024 * 1024) +#define PATCHDL_BUF_MAX_MANIFEST (64 * 1024 * 1024) + #ifdef PATCHDL_HAVE_CURL static const char *ALLOWED_HOSTS[] = { @@ -35,13 +46,15 @@ static const char *ALLOWED_HOSTS[] = { static int host_allowed(const char *host) { size_t hlen = strlen(host); + /* DNS is case-insensitive; an upstream redirect to "SGST.prod..." would + otherwise drop out of the allowlist. */ for (int i = 0; ALLOWED_HOSTS[i]; i++) { - if (!strcmp(host, ALLOWED_HOSTS[i])) + if (!strcasecmp(host, ALLOWED_HOSTS[i])) return 1; size_t alen = strlen(ALLOWED_HOSTS[i]); if (hlen > alen + 1 && host[hlen - alen - 1] == '.' && - !strcmp(host + hlen - alen, ALLOWED_HOSTS[i])) + !strcasecmp(host + hlen - alen, ALLOWED_HOSTS[i])) return 1; } return 0; @@ -145,6 +158,10 @@ dns_resolve(const char *host, char *ip_out, size_t ip_sz) { while (pos < (size_t)n) { if (!resp[pos]) { pos++; break; } if ((resp[pos] & 0xC0) == 0xC0) { pos += 2; break; } + /* Bounds-check the label length BEFORE the increment — a malformed + response with a 0xFF label byte near the end would otherwise walk + past `n`. */ + if (pos + 1 + (size_t)resp[pos] >= (size_t)n) { g_dns_step = 5; return -1; } pos += 1 + resp[pos]; } if (pos + 4 > (size_t)n) { g_dns_step = 5; return -1; } @@ -156,8 +173,10 @@ dns_resolve(const char *host, char *ip_out, size_t ip_sz) { if ((resp[pos] & 0xC0) == 0xC0) { pos += 2; } else { - while (pos < (size_t)n && resp[pos]) + while (pos < (size_t)n && resp[pos]) { + if (pos + 1 + (size_t)resp[pos] >= (size_t)n) break; pos += 1 + resp[pos]; + } pos++; } if (pos + 10 > (size_t)n) break; @@ -222,7 +241,12 @@ static size_t write_cb(void *ptr, size_t size, size_t nmemb, void *userdata) { patchdl_buf_t *b = userdata; size_t total = size * nmemb; - char *newp = realloc(b->data, b->size + total + 1); + char *newp; + /* Overflow guard before the cap check (b->size+total may wrap on 32-bit). */ + if (total > (size_t)-1 - b->size - 1) return 0; + /* Cap accumulation so a hostile CDN can't drive unbounded RAM growth. */ + if (b->max && b->size + total > b->max) return 0; + newp = realloc(b->data, b->size + total + 1); if (!newp) return 0; b->data = newp; memcpy(b->data + b->size, ptr, total); @@ -252,7 +276,11 @@ patchdl_http_get(const char *url, patchdl_buf_t *out) { snprintf(resolve_80, sizeof(resolve_80), "%s:80:%s", host, ip); resolve_list = curl_slist_append(resolve_list, resolve_80); - memset(out, 0, sizeof(*out)); + { + size_t caller_max = out->max; + memset(out, 0, sizeof(*out)); + out->max = caller_max; + } curl = curl_easy_init(); if (!curl) { curl_slist_free_all(resolve_list); return -1; } @@ -277,6 +305,12 @@ patchdl_http_get(const char *url, patchdl_buf_t *out) { curl_easy_setopt(curl, CURLOPT_TIMEOUT, 15L); curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 3L); + /* Redirects must stay on HTTPS — host_allowed gates the initial URL, but + once libcurl follows a 302 we want the protocol pinned too. The _STR + variants replaced the bitfield options in libcurl 7.85. */ + curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https"); + curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https"); + curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); curl_easy_setopt(curl, CURLOPT_USERAGENT, "patchdl/1.0"); res = curl_easy_perform(curl); @@ -473,8 +507,24 @@ patchdl_http_download(const char *url, const char *dest_path, return patchdl_http_download_progress(url, dest_path, bytes_out, NULL, NULL); } +/* Substring scan bounded to [p, limit). NULL limit means search to NUL. + Returns NULL if needle is not found before limit. */ +static const char * +strstr_bounded(const char *p, const char *needle, const char *limit) { + const char *hit = strstr(p, needle); + if (!hit) return NULL; + if (limit && hit >= limit) return NULL; + return hit; +} + +/* Read "key": "value" starting from p. Search and read are bounded by `limit` + (pass NULL to search to end of buffer). Decodes \\ \" \/ \n \r \t \b \f; any + other \X is copied without the backslash. \uXXXX is left as the raw 6 bytes + (we don't need Unicode for manifest fields). limit==NULL keeps legacy + end-of-string scope for callers that don't need the cap. */ static int -json_string_after(const char *p, const char *key, char *out, size_t out_sz) { +json_string_after(const char *p, const char *key, char *out, size_t out_sz, + const char *limit) { char needle[48]; const char *q; size_t n = 0; @@ -482,35 +532,59 @@ json_string_after(const char *p, const char *key, char *out, size_t out_sz) { if (!p || !out || out_sz == 0) return -1; out[0] = '\0'; snprintf(needle, sizeof(needle), "\"%s\"", key); - q = strstr(p, needle); + q = strstr_bounded(p, needle, limit); if (!q) return -1; q += strlen(needle); - while (*q == ' ' || *q == '\t' || *q == '\r' || *q == '\n') q++; + while ((!limit || q < limit) && + (*q == ' ' || *q == '\t' || *q == '\r' || *q == '\n')) q++; + if (limit && q >= limit) return -1; if (*q++ != ':') return -1; - while (*q == ' ' || *q == '\t' || *q == '\r' || *q == '\n') q++; + while ((!limit || q < limit) && + (*q == ' ' || *q == '\t' || *q == '\r' || *q == '\n')) q++; + if (limit && q >= limit) return -1; if (*q++ != '"') return -1; - while (*q && *q != '"' && n + 1 < out_sz) { - if (*q == '\\' && q[1]) q++; - out[n++] = *q++; + while ((!limit || q < limit) && *q && *q != '"' && n + 1 < out_sz) { + if (*q == '\\' && q[1] && (!limit || q + 1 < limit)) { + q++; + switch (*q) { + case '"': out[n++] = '"'; break; + case '\\': out[n++] = '\\'; break; + case '/': out[n++] = '/'; break; + case 'n': out[n++] = '\n'; break; + case 'r': out[n++] = '\r'; break; + case 't': out[n++] = '\t'; break; + case 'b': out[n++] = '\b'; break; + case 'f': out[n++] = '\f'; break; + default: out[n++] = *q; break; /* unknown escape: keep payload */ + } + q++; + } else { + out[n++] = *q++; + } } out[n] = '\0'; return n ? 0 : -1; } static int -json_u64_after(const char *p, const char *key, unsigned long long *out) { +json_u64_after(const char *p, const char *key, unsigned long long *out, + const char *limit) { char needle[48]; const char *q; if (!p || !out) return -1; snprintf(needle, sizeof(needle), "\"%s\"", key); - q = strstr(p, needle); + q = strstr_bounded(p, needle, limit); if (!q) return -1; q += strlen(needle); - while (*q == ' ' || *q == '\t' || *q == '\r' || *q == '\n') q++; + while ((!limit || q < limit) && + (*q == ' ' || *q == '\t' || *q == '\r' || *q == '\n')) q++; + if (limit && q >= limit) return -1; if (*q++ != ':') return -1; - while (*q == ' ' || *q == '\t' || *q == '\r' || *q == '\n') q++; + while ((!limit || q < limit) && + (*q == ' ' || *q == '\t' || *q == '\r' || *q == '\n')) q++; + if (limit && q >= limit) return -1; if (*q < '0' || *q > '9') return -1; *out = strtoull(q, NULL, 10); return 0; @@ -530,6 +604,8 @@ patchdl_http_download_manifest_progress(const char *manifest_url, int count = 0, started, rc = -1; if (bytes_out) *bytes_out = 0; + memset(&manifest, 0, sizeof(manifest)); + manifest.max = PATCHDL_BUF_MAX_MANIFEST; if (patchdl_http_get(manifest_url, &manifest)) return -1; if (!manifest.data || !manifest.size) { @@ -545,7 +621,7 @@ patchdl_http_download_manifest_progress(const char *manifest_url, /* Bound the scan to the pieces array; otherwise a later "url" key in the manifest (e.g. playgoChunkCrcUrl) could be appended as a bogus piece. */ pieces_end = strchr(pieces, ']'); - json_u64_after(manifest.data, "originalFileSize", &manifest_total); + json_u64_after(manifest.data, "originalFileSize", &manifest_total, NULL); /* Resume: reopen the existing partial and keep its bytes; else start clean. Fully-downloaded pieces are skipped; the one piece that was only partially @@ -569,11 +645,13 @@ patchdl_http_download_manifest_progress(const char *manifest_url, int have_offset, drc; const char *want_hash; const char *obj_end = strchr(p, '}'); + const char *piece_limit = (obj_end && (!pieces_end || obj_end < pieces_end)) + ? obj_end : pieces_end; - if (json_string_after(p, "url", url, sizeof(url))) + if (json_string_after(p, "url", url, sizeof(url), piece_limit)) break; - json_u64_after(p, "fileSize", &expected); - have_offset = (json_u64_after(p, "fileOffset", &offset) == 0); + json_u64_after(p, "fileSize", &expected, piece_limit); + have_offset = (json_u64_after(p, "fileOffset", &offset, piece_limit) == 0); /* Piece already fully present from a previous run: skip the download. */ if (!started && have_offset && expected && @@ -614,7 +692,7 @@ patchdl_http_download_manifest_progress(const char *manifest_url, /* A ranged piece can't be hashed (only its tail is fetched). */ want_hash = NULL; if (range_start == 0 && verify) { - json_string_after(p, "hashValue", hash, sizeof(hash)); + json_string_after(p, "hashValue", hash, sizeof(hash), piece_limit); want_hash = hash[0] ? hash : NULL; } @@ -634,7 +712,8 @@ patchdl_http_download_manifest_progress(const char *manifest_url, total = (long long)offset; range_start = 0; if (verify) { - json_string_after(p, "hashValue", hash, sizeof(hash)); + json_string_after(p, "hashValue", hash, sizeof(hash), + piece_limit); want_hash = hash[0] ? hash : NULL; } progress.base = total; @@ -859,6 +938,8 @@ patchdl_fetch_manifest(const char *manifest_url, patchdl_manifest_t *out) { long long running = 0; memset(out, 0, sizeof(*out)); + memset(&buf, 0, sizeof(buf)); + buf.max = PATCHDL_BUF_MAX_MANIFEST; if (patchdl_http_get(manifest_url, &buf)) return -1; if (!buf.data || !buf.size) { free(buf.data); return -1; } @@ -872,20 +953,33 @@ patchdl_fetch_manifest(const char *manifest_url, patchdl_manifest_t *out) { out->pieces = calloc((size_t)cap, sizeof(patchdl_piece_t)); if (!out->pieces) { free(buf.data); return -1; } + /* Sanity caps: refuse a manifest that would let a CDN drive multi-TB + allocations or millions of pieces. The biggest real PS5 patch we've + seen is ~70 GB / 18 pieces; these limits leave room to spare. */ + if (cap > PATCHDL_MAX_PIECES) { free(buf.data); return -1; } + p = pieces; while ((p = strstr(p, "\"url\"")) && (!pieces_end || p < pieces_end) && n < cap) { char url[768] = {0}; unsigned long long sz = 0, off = 0; const char *obj_end = strchr(p, '}'); + const char *piece_limit = (obj_end && (!pieces_end || obj_end < pieces_end)) + ? obj_end : pieces_end; - if (json_string_after(p, "url", url, sizeof(url))) + if (json_string_after(p, "url", url, sizeof(url), piece_limit)) break; - json_u64_after(p, "fileSize", &sz); - if (json_u64_after(p, "fileOffset", &off) != 0) + json_u64_after(p, "fileSize", &sz, piece_limit); + if (json_u64_after(p, "fileOffset", &off, piece_limit) != 0) off = (unsigned long long)running; /* no offset -> assume contiguous */ - /* Validate tiling: pieces must be in order, contiguous, non-empty. */ - if ((long long)off != running || sz == 0) { + /* Validate tiling: pieces must be in order, contiguous, non-empty, + and each individually under the per-piece cap. */ + if ((long long)off != running || sz == 0 || sz > PATCHDL_MAX_PIECE_BYTES) { + patchdl_manifest_free(out); + free(buf.data); + return -1; + } + if ((unsigned long long)running + sz > PATCHDL_MAX_TOTAL_BYTES) { patchdl_manifest_free(out); free(buf.data); return -1; @@ -894,7 +988,7 @@ patchdl_fetch_manifest(const char *manifest_url, patchdl_manifest_t *out) { out->pieces[n].offset = (long long)off; out->pieces[n].size = (long long)sz; json_string_after(p, "hashValue", out->pieces[n].hash, - sizeof(out->pieces[n].hash)); + sizeof(out->pieces[n].hash), piece_limit); if (!out->pieces[n].url) { patchdl_manifest_free(out); free(buf.data); diff --git a/src/patchdl_net.h b/src/patchdl_net.h index 9540721..e9770f5 100644 --- a/src/patchdl_net.h +++ b/src/patchdl_net.h @@ -6,6 +6,7 @@ typedef struct { char *data; size_t size; size_t cap; + size_t max; /* 0 = unbounded (legacy). Otherwise write_cb fails past this. */ } patchdl_buf_t; patchdl_buf_t *patchdl_buf_new(void); diff --git a/src/patchdl_verxml.c b/src/patchdl_verxml.c index e1bdd43..9d09c21 100644 --- a/src/patchdl_verxml.c +++ b/src/patchdl_verxml.c @@ -66,14 +66,20 @@ ver_gt(const char *a, const char *b) { from a string such as nptitleid, manifest_url, or delta_url. */ static void extract_title_id(const char *s, char *out, size_t sz) { + size_t len; out[0] = '\0'; if (sz < 10 || !s) return; - for (const char *p = s; p[0] && p[8]; p++) { + len = strlen(s); + if (len < 9) return; + /* `len - 9` is the last position where a 9-char id can still fit; this + avoids reading p[8] past the NUL terminator. */ + for (size_t i = 0; i <= len - 9; i++) { + const char *p = s + i; int ok = 1; - for (int i = 0; i < 4 && ok; i++) - if (p[i] < 'A' || p[i] > 'Z') ok = 0; - for (int i = 4; i < 9 && ok; i++) - if (p[i] < '0' || p[i] > '9') ok = 0; + for (int k = 0; k < 4 && ok; k++) + if (p[k] < 'A' || p[k] > 'Z') ok = 0; + for (int k = 4; k < 9 && ok; k++) + if (p[k] < '0' || p[k] > '9') ok = 0; if (ok) { memcpy(out, p, 9); out[9] = '\0'; @@ -177,6 +183,10 @@ patchdl_verxml_query(const char *url, uint32_t fw_bin, patchdl_verinfo_t *out) { if (!url || !out) return -1; memset(out, 0, sizeof(*out)); + /* version.xml is a few KB in practice; cap to 16 MiB so a misbehaving CDN + can't slurp unbounded RAM into the buffer. */ + memset(&buf, 0, sizeof(buf)); + buf.max = 16 * 1024 * 1024; if (patchdl_http_get(url, &buf)) return -1; if (!buf.data || !buf.size) { free(buf.data); return -1; }