security: harden safetensors/JSON parsers against malicious model files
A downloaded (supply-chain) model file was fully trusted by the loader. Three memory-safety holes, all reachable by pointing the engine at a crafted shard — demonstrated crashing on pre-fix, now rejected fail-closed: st.h (safetensors): - header length `hlen` (u64 from the file) was unbounded before malloc(hlen+1): a crafted value overflows (malloc(0) then hdr[hlen]=0 OOB) or forces a giant allocation. Now bounded to the file size and a 512 MB cap; malloc NULL-checked. - json_get() returns NULL for missing/mistyped fields, but dtype/data_offsets/ shape were dereferenced blind (off->kids[0]) — a header omitting data_offsets SIGSEGV'd (verified). Now type/arity-checked before use. - data_offsets [a0,b0] were trusted: b0<a0 gave a negative nbytes -> malloc((size_t)) giant and an oversized memcpy into the caller's buffer in st_read_f32 (heap overflow); off could point outside the file. Now validated 0<=a0<=b0 and data_start+b0<=filesize. json.h: j_parse_val recursed with no depth limit -> stack overflow on nested input like [[[[...]]]]. Added J_MAX_DEPTH=1024 (headers are ~3 deep); wide-but- flat objects like the GLM header are unaffected (depth is decremented per return). eval_glm.py: tempfile.mktemp() -> mkstemp() — closes the TOCTOU/symlink race on a shared tmp dir (CWE-377). Network path (openai_server.py + serve SUBMIT parser) audited separately and is already sound: hmac.compare_digest auth, MAX_BODY cap, resolve()+relative_to traversal guard, list-form subprocess, bounded/validated SUBMIT header. All 62 tests pass; valid GLM/OLMoE shards load unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -26,8 +26,14 @@ typedef struct {
|
||||
const char *s;
|
||||
char *arena; /* buffer per le stringhe smontate */
|
||||
size_t acap, aoff;
|
||||
int depth; /* annidamento corrente: bound contro lo stack-overflow
|
||||
* da JSON malevolo tipo [[[[...]]]] (discesa ricorsiva) */
|
||||
} jparser;
|
||||
|
||||
/* tetto di annidamento: gli header safetensors / config sono piatti (profondita'
|
||||
* ~3). 1024 e' larghissimo per input legittimi e ben sotto il limite di stack. */
|
||||
#define J_MAX_DEPTH 1024
|
||||
|
||||
static char *j_dup(jparser *p, const char *b, int n) {
|
||||
/* ogni stringa ha la sua allocazione: un'arena con realloc sposterebbe il
|
||||
* buffer invalidando i puntatori gia' emessi (use-after-free). */
|
||||
@@ -91,10 +97,11 @@ static jval *j_parse_val(jparser *p) {
|
||||
char c = *p->s;
|
||||
if (c == '"') { jval *v = j_new(J_STR); v->str = j_parse_str_raw(p); return v; }
|
||||
if (c == '{') {
|
||||
if (++p->depth > J_MAX_DEPTH) { p->depth--; return j_new(J_NULL); }
|
||||
p->s++; jval *v = j_new(J_OBJ);
|
||||
int cap = 8; v->keys = malloc(cap * sizeof(char*)); v->kids = malloc(cap * sizeof(jval*));
|
||||
j_ws(p);
|
||||
if (*p->s == '}') { p->s++; return v; }
|
||||
if (*p->s == '}') { p->s++; p->depth--; return v; }
|
||||
for (;;) {
|
||||
j_ws(p);
|
||||
char *key = j_parse_str_raw(p);
|
||||
@@ -107,13 +114,15 @@ static jval *j_parse_val(jparser *p) {
|
||||
if (*p->s == '}') { p->s++; break; }
|
||||
break;
|
||||
}
|
||||
p->depth--;
|
||||
return v;
|
||||
}
|
||||
if (c == '[') {
|
||||
if (++p->depth > J_MAX_DEPTH) { p->depth--; return j_new(J_NULL); }
|
||||
p->s++; jval *v = j_new(J_ARR);
|
||||
int cap = 8; v->kids = malloc(cap * sizeof(jval*));
|
||||
j_ws(p);
|
||||
if (*p->s == ']') { p->s++; return v; }
|
||||
if (*p->s == ']') { p->s++; p->depth--; return v; }
|
||||
for (;;) {
|
||||
jval *val = j_parse_val(p);
|
||||
if (v->len == cap) { cap *= 2; v->kids = realloc(v->kids, cap*sizeof(jval*)); }
|
||||
@@ -123,6 +132,7 @@ static jval *j_parse_val(jparser *p) {
|
||||
if (*p->s == ']') { p->s++; break; }
|
||||
break;
|
||||
}
|
||||
p->depth--;
|
||||
return v;
|
||||
}
|
||||
if (c == 't') { p->s += 4; jval *v = j_new(J_BOOL); v->boolean = 1; return v; }
|
||||
@@ -134,7 +144,7 @@ static jval *j_parse_val(jparser *p) {
|
||||
|
||||
/* API */
|
||||
static jval *json_parse(const char *text, char **arena_out) {
|
||||
jparser p = { text, NULL, 0, 0 };
|
||||
jparser p = { text, NULL, 0, 0, 0 };
|
||||
jval *v = j_parse_val(&p);
|
||||
if (arena_out) *arena_out = p.arena; else free(p.arena);
|
||||
return v;
|
||||
|
||||
@@ -14,9 +14,15 @@
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <dirent.h>
|
||||
#include <sys/stat.h>
|
||||
#include "json.h"
|
||||
#include "compat.h"
|
||||
|
||||
/* tetto sulla dimensione dell'header safetensors: gli header reali sono piccoli
|
||||
* (KB..pochi MB). Un file crafted che dichiara un hlen enorme causerebbe una
|
||||
* malloc gigante prima ancora di leggere: lo respingiamo. */
|
||||
#define ST_MAX_HEADER (512ll << 20)
|
||||
|
||||
typedef struct {
|
||||
char *name;
|
||||
int fd;
|
||||
@@ -118,14 +124,27 @@ static void st_init(shards *S, const char *snap_dir) {
|
||||
|
||||
for (int fi = 0; fi < nf; fi++) {
|
||||
int fd = st_open_fd(S, files[fi]);
|
||||
struct stat sst;
|
||||
if (fstat(fd, &sst) != 0) { perror("fstat shard"); exit(1); }
|
||||
int64_t fsz = (int64_t)sst.st_size;
|
||||
uint64_t hlen;
|
||||
if (pread(fd, &hlen, 8, 0) != 8) { perror("pread hlen"); exit(1); }
|
||||
/* file malevolo/troncato: hlen deve stare nel file dopo gli 8 byte di
|
||||
* prefisso e sotto il tetto. Senza questo bound hlen+1 puo' andare in
|
||||
* overflow (malloc(0) e poi hdr[hlen]=0 fuori limiti) o forzare una
|
||||
* malloc gigante. */
|
||||
if (fsz < 8 || hlen > (uint64_t)(fsz - 8) || hlen > (uint64_t)ST_MAX_HEADER) {
|
||||
fprintf(stderr, "%s: bad safetensors header length %llu (file %lld bytes)\n",
|
||||
files[fi], (unsigned long long)hlen, (long long)fsz); exit(1); }
|
||||
char *hdr = malloc(hlen + 1);
|
||||
if (!hdr) { perror("malloc safetensors header"); exit(1); }
|
||||
if (pread(fd, hdr, hlen, 8) != (ssize_t)hlen) { perror("pread hdr"); exit(1); }
|
||||
hdr[hlen] = 0;
|
||||
int64_t data_start = 8 + (int64_t)hlen;
|
||||
char *arena = NULL;
|
||||
jval *root = json_parse(hdr, &arena);
|
||||
if (!root || root->t != J_OBJ) {
|
||||
fprintf(stderr, "%s: safetensors header is not a JSON object\n", files[fi]); exit(1); }
|
||||
for (int i = 0; i < root->len; i++) {
|
||||
const char *name = root->keys[i];
|
||||
if (!strcmp(name, "__metadata__")) continue;
|
||||
@@ -133,7 +152,21 @@ static void st_init(shards *S, const char *snap_dir) {
|
||||
jval *dt = json_get(m, "dtype");
|
||||
jval *off = json_get(m, "data_offsets");
|
||||
jval *shp = json_get(m, "shape");
|
||||
/* un header crafted puo' omettere i campi o dare tipi sbagliati:
|
||||
* senza questi guard si dereferenzia NULL (json_get) o si legge
|
||||
* off->kids[0/1] oltre i limiti dell'array. */
|
||||
if (!dt || dt->t != J_STR || !off || off->t != J_ARR || off->len < 2 ||
|
||||
!shp || shp->t != J_ARR) {
|
||||
fprintf(stderr, "%s: tensor '%s' has malformed dtype/data_offsets/shape\n",
|
||||
files[fi], name); exit(1); }
|
||||
int64_t a0 = (int64_t)off->kids[0]->num, b0 = (int64_t)off->kids[1]->num;
|
||||
/* offset dichiarati dal file: non-negativi, ordinati e dentro al
|
||||
* file. Altrimenti nbytes=b0-a0 diventa negativo -> malloc((size_t))
|
||||
* gigante e la memcpy in st_read_f32 sfora il buffer del chiamante;
|
||||
* oppure off punta fuori dal file. */
|
||||
if (a0 < 0 || b0 < a0 || data_start + b0 > fsz) {
|
||||
fprintf(stderr, "%s: tensor '%s' data_offsets [%lld,%lld] out of file bounds (%lld)\n",
|
||||
files[fi], name, (long long)a0, (long long)b0, (long long)fsz); exit(1); }
|
||||
int64_t numel = 1; for (int k = 0; k < shp->len; k++) numel *= (int64_t)shp->kids[k]->num;
|
||||
if (S->n == S->cap) { S->cap *= 2; S->t = realloc(S->t, S->cap*sizeof(st_tensor)); }
|
||||
st_tensor *t = &S->t[S->n++];
|
||||
@@ -184,6 +217,7 @@ static int64_t st_read_f32(shards *S, const char *name, float *out, int drop) {
|
||||
st_tensor *t = st_find(S, name);
|
||||
if (!t) { fprintf(stderr, "missing tensor: %s\n", name); exit(1); }
|
||||
void *raw = malloc(t->nbytes);
|
||||
if (!raw) { fprintf(stderr, "malloc %lld bytes for tensor %s failed\n", (long long)t->nbytes, name); exit(1); }
|
||||
if (pread(t->fd, raw, t->nbytes, t->off) != t->nbytes) { perror("pread data"); exit(1); }
|
||||
if (t->dtype == 2) {
|
||||
memcpy(out, raw, t->nbytes);
|
||||
|
||||
+5
-2
@@ -134,8 +134,11 @@ def main():
|
||||
for r in reqs[:3]: print(" example request:", r[:80], "...", file=sys.stderr)
|
||||
print("DRY: request construction and tokenization passed. Engine was not run.", file=sys.stderr); return
|
||||
|
||||
req_path = tempfile.mktemp(suffix=".txt")
|
||||
open(req_path, "w").write("\n".join(reqs) + "\n")
|
||||
# mkstemp (non mktemp): crea il file atomicamente con permessi 0600, niente
|
||||
# race TOCTOU/symlink su una tmp dir condivisa (CWE-377).
|
||||
fd, req_path = tempfile.mkstemp(suffix=".txt")
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write("\n".join(reqs) + "\n")
|
||||
env = dict(os.environ, SNAP=a.snap, SCORE=req_path)
|
||||
if a.ram: env["RAM_GB"] = str(a.ram)
|
||||
cmd = [a.glm, str(a.cap)] + a.bits.split()
|
||||
|
||||
Reference in New Issue
Block a user