code wiki / _hdl_build / nx_cms_fuzz_gate.nx

nx_cms_fuzz_gate.nx source

↩ module page · 276 lines · 11887 B

1// nx_cms_fuzz_gate.nx -- C6b SECURITY GATE (the Engineer/Auditor's adversarial instrument). The CMS 2// editor is the ONLY untrusted-input surface (spec rule 12); this gate ATTACKS the real running daemon 3// with hostile HTTP and proves it degrades gracefully (rule 14): never crashes, never reflects an 4// injected response header, never serves a route that should not exist, and is STILL ALIVE after the 5// barrage (a clean GET / succeeds at the end). Boots its OWN nx_cms_admin via nx_sov_build_run (prove- 6// all: /tmp is contested), plays the attacks with the team's own HTTP client, appends a row=nx_cms_fuzz 7// verdict=PASS|FAIL line to knowledge/status/cms_gate.log so the arc board routes on it. Exit 0 iff all 8// attacks contained. license_tier: ORIGINAL 9import "nx_syscalls.nx" 10import "nx_connect.nx" // bounded connect: a raw sys_connect hangs ~127s on a black-holed host 11import "_hdl_build/nx_kill_portable.nx" 12 13const CF_PORT: i64 = 8078 14const CF_SITE: *u8 = "/tmp/_cms_fuzz_site" 15 16func cf_w(s: *u8) -> i64 { var n: i64=0; while s[n]!=(0 as u8){n=n+1} sys_write(1,s,n); return 0 } 17func cf_len(s: *u8) -> i64 { var n: i64=0; while s[n]!=(0 as u8){n=n+1} return n } 18func cf_cat(dst: *u8, off: i64, s: *u8) -> i64 { 19 var o: i64 = off 20 var k: i64 = 0 21 while s[k] != (0 as u8) { dst[o] = s[k]; o = o + 1; k = k + 1 } 22 return o 23} 24func cf_index(hay: *u8, n: i64, needle: *u8) -> i64 { 25 let nl: i64 = cf_len(needle) 26 var i: i64 = 0 27 while i + nl <= n { 28 var q: i64 = 0 29 var ok: i64 = 1 30 while q < nl { if (hay[i+q] as i64) != (needle[q] as i64) { ok = 0; q = nl } q = q + 1 } 31 if ok == 1 { return i } 32 i = i + 1 33 } 34 return 0 - 1 35} 36func cf_has(hay: *u8, n: i64, needle: *u8) -> i64 { if cf_index(hay, n, needle) >= 0 { return 1 } return 0 } 37 38func cf_mkdir(path: *u8, mode: i64) -> i64 { 39 let nb: *i64 = sys_mmap(8) as *i64 40 nb[0] = 258 41 return __syscall(nb[0], 0 - 100, path, mode, 0, 0, 0) 42} 43func cf_copy(src: *u8, dst: *u8) -> i64 { 44 let sf: i64 = sys_openat_rd(src) 45 if sf < 0 { return 0 } 46 let df: i64 = sys_openat_wr(dst, 0x1a4) 47 if df < 0 { sys_close(sf); return 0 } 48 let b: *u8 = sys_mmap(65536) 49 var go: i64 = 1 50 while go == 1 { 51 let r: i64 = sys_read(sf, b, 65536) 52 go = 0 53 if r > 0 { sys_write(df, b, r); go = 1 } 54 } 55 sys_close(sf); sys_close(df) 56 return 1 57} 58 59// send raw bytes, read whatever comes back (server closes). returns response len, -1 = connect/refused. 60func cf_send(raw: *u8, rl: i64, resp: *u8, cap: i64) -> i64 { 61 let fd: i64 = sys_socket(2, 1, 0) 62 if fd < 0 { return 0 - 1 } 63 let addr: *u8 = sys_mmap(16) 64 addr[0] = 2 as u8; addr[1] = 0 as u8 65 addr[2] = ((CF_PORT >> 8) & 0xff) as u8 66 addr[3] = (CF_PORT & 0xff) as u8 67 addr[4] = 127 as u8; addr[5] = 0 as u8; addr[6] = 0 as u8; addr[7] = 1 as u8 68 var zi: i64 = 8 69 while zi < 16 { addr[zi] = 0 as u8; zi = zi + 1 } 70 if nx_connect_bounded(fd, addr, 16, NX_CONN_DEFAULT_MS) < 0 { sys_close(fd); return 0 - 1 } 71 var off: i64 = 0 72 var go: i64 = 1 73 while go == 1 { 74 go = 0 75 if off < rl { 76 let w: i64 = sys_write(fd, (raw + off) as *u8, rl - off) 77 if w > 0 { off = off + w; go = 1 } 78 } 79 } 80 sys_set_socket_timeout(fd, 6) 81 var total: i64 = 0 82 go = 1 83 while go == 1 { 84 go = 0 85 if total < cap { 86 let r: i64 = sys_read(fd, (resp + total) as *u8, cap - total) 87 if r > 0 { total = total + r; go = 1 } 88 } 89 } 90 sys_close(fd) 91 return total 92} 93 94// a daemon is "alive + contained" if a clean GET / returns 200 with the seed marker 95func cf_alive(resp: *u8, cap: i64) -> i64 { 96 let req: *u8 = sys_mmap(256) 97 var o: i64 = cf_cat(req, 0, "GET / HTTP/1.1\r\nHost: f\r\n\r\n" as *u8) 98 let rn: i64 = cf_send(req, o, resp, cap) 99 if rn <= 0 { return 0 } 100 if cf_has(resp, rn, "200 OK" as *u8) == 1 { if cf_has(resp, rn, "Andelin West Law" as *u8) == 1 { return 1 } } 101 return 0 102} 103 104// a response is "not a server crash" = no 5xx and (if anything came back) it parses as an HTTP status. 105// connection refused / hard hang (rn<=0) on an ATTACK is acceptable rejection; what we forbid is a 5xx 106// (uncaught fault) OR the daemon being dead AFTERWARDS (checked separately by cf_alive). 107func cf_no_5xx(resp: *u8, rn: i64) -> i64 { 108 if rn <= 0 { return 1 } 109 if cf_has(resp, rn, " 500" as *u8) == 1 { return 0 } 110 if cf_has(resp, rn, " 502" as *u8) == 1 { return 0 } 111 if cf_has(resp, rn, " 503" as *u8) == 1 { return 0 } 112 return 1 113} 114 115func cf_row(id: i64, ok: i64, what: *u8) -> i64 { 116 cf_w("FUZZROW " as *u8) 117 let d: *u8 = sys_mmap(8); d[0] = (48 + id/10) as u8; d[1] = (48 + id%10) as u8; d[2] = 32 as u8; d[3] = 0 as u8 118 cf_w(d) 119 if ok == 1 { cf_w("PASS " as *u8) } 120 if ok == 0 { cf_w("FAIL " as *u8) } 121 cf_w(what); cf_w("\n" as *u8) 122 return ok 123} 124 125func main() -> i64 { 126 // ---- site dir + pw (sha256 of a throwaway pw; attacks never need to authenticate) ---- 127 cf_mkdir(CF_SITE, 0x1ed) 128 cf_copy("knowledge/cms/andelinwest/template.html" as *u8, "/tmp/_cms_fuzz_site/template.html" as *u8) 129 cf_copy("knowledge/cms/andelinwest/content.txt" as *u8, "/tmp/_cms_fuzz_site/content.txt" as *u8) 130 let pf: i64 = sys_openat_wr("/tmp/_cms_fuzz_site/admin_pw.sha256" as *u8, 0x1a4) 131 // sha256("x") -- value irrelevant; just needs >=64 bytes so the daemon starts 132 sys_write(pf, "2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881" as *u8, 64) 133 sys_close(pf) 134 135 // ---- rebuild + boot the real admin daemon ---- 136 let bpid: i64 = sys_fork() 137 if bpid == 0 { 138 let dn: i64 = sys_openat_wr("/dev/null" as *u8, 0x1a4) 139 if dn >= 0 { sys_dup3(dn, 1, 0); sys_dup3(dn, 2, 0) } 140 let bargv: *i64 = sys_mmap(32) as *i64 141 bargv[0] = "_offc/nx_sov_build_run.elf" as *u8 as i64 142 bargv[1] = "nx_cms_admin" as *u8 as i64 143 bargv[2] = 0 144 let benvp: *i64 = sys_mmap(16) as *i64 145 benvp[0] = 0 146 sys_execve("_offc/nx_sov_build_run.elf" as *u8, bargv, benvp); sys_exit(127) 147 } 148 let bst: *i64 = sys_mmap(16) as *i64 149 sys_wait4(bpid, bst, 0) 150 let bchk: i64 = sys_openat_rd("/tmp/nx_cms_admin.sov.elf" as *u8) 151 if bchk < 0 { cf_w("FUZZGATE ADMIN-BUILD-MISSING\n" as *u8); sys_exit(1) } 152 sys_close(bchk) 153 154 let pid: i64 = sys_fork() 155 if pid == 0 { 156 let lg: i64 = sys_openat_wr("/tmp/_cms_fuzz_admin.log" as *u8, 0x1a4) 157 if lg >= 0 { sys_dup3(lg, 1, 0); sys_dup3(lg, 2, 0) } 158 let argvv: *i64 = sys_mmap(8*6) as *i64 159 argvv[0] = "/tmp/nx_cms_admin.sov.elf" as *u8 as i64 160 argvv[1] = "8078" as *u8 as i64 161 argvv[2] = CF_SITE as i64 162 argvv[3] = "200" as *u8 as i64 163 argvv[4] = 0 164 let envp: *i64 = sys_mmap(16) as *i64 165 envp[0] = 0 166 sys_execve("/tmp/nx_cms_admin.sov.elf" as *u8, argvv, envp); sys_exit(127) 167 } 168 169 let resp: *u8 = sys_mmap(1048576) 170 let req: *u8 = sys_mmap(1048576) 171 172 // boot-poll 173 var booted: i64 = 0 174 var tries: i64 = 0 175 while tries < 50 { 176 if cf_alive(resp, 1048575) == 1 { booted = 1; tries = 50 } 177 if booted == 0 { sys_sleep_ms(100); tries = tries + 1 } 178 } 179 var pass: i64 = 0 180 var rows: i64 = 0 181 var rn: i64 = 0 182 var ok: i64 = 0 183 184 // A0: baseline -- the daemon is up 185 rows = rows + 1; pass = pass + cf_row(0, booted, "daemon boots + serves baseline" as *u8) 186 187 // A1: oversized header line (64KB of 'A' in a bogus header) -> no crash, still alive 188 var o: i64 = cf_cat(req, 0, "GET / HTTP/1.1\r\nX-Junk: " as *u8) 189 var fill: i64 = 0 190 while fill < 65536 { req[o] = 65 as u8; o = o + 1; fill = fill + 1 } 191 o = cf_cat(req, o, "\r\nHost: f\r\n\r\n" as *u8) 192 rn = cf_send(req, o, resp, 1048575) 193 ok = cf_no_5xx(resp, rn) 194 rows = rows + 1; pass = pass + cf_row(1, ok, "64KB header line contained (no 5xx)" as *u8) 195 196 // A2: POST /admin/save with Content-Length but NO body (truncated) -> no hang-crash 197 o = cf_cat(req, 0, "POST /admin/save HTTP/1.1\r\nHost: f\r\nContent-Length: 5000\r\n\r\n" as *u8) 198 rn = cf_send(req, o, resp, 1048575) 199 ok = cf_no_5xx(resp, rn) 200 rows = rows + 1; pass = pass + cf_row(2, ok, "truncated body (CL lies) contained" as *u8) 201 202 // A3: header-injection attempt -- a stored-style value with CRLF; the response must NOT contain an 203 // injected "X-Pwned" header (we send it as a raw body field that a naive renderer might echo) 204 o = cf_cat(req, 0, "POST /admin/login HTTP/1.1\r\nHost: f\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 41\r\n\r\npw=x%0d%0aX-Pwned%3a+1%0d%0aJunk%3a+y" as *u8) 205 rn = cf_send(req, o, resp, 1048575) 206 ok = 1 207 if cf_has(resp, rn, "X-Pwned" as *u8) == 1 { ok = 0 } 208 if cf_no_5xx(resp, rn) == 0 { ok = 0 } 209 rows = rows + 1; pass = pass + cf_row(3, ok, "CRLF header-injection not reflected" as *u8) 210 211 // A4: bogus method -> 404/handled, no crash 212 o = cf_cat(req, 0, "FROBNICATE /admin HTTP/1.1\r\nHost: f\r\n\r\n" as *u8) 213 rn = cf_send(req, o, resp, 1048575) 214 ok = cf_no_5xx(resp, rn) 215 rows = rows + 1; pass = pass + cf_row(4, ok, "unknown method handled" as *u8) 216 217 // A5: path-traversal in the route -> must NOT serve the pw hash file; no crash 218 o = cf_cat(req, 0, "GET /../admin_pw.sha256 HTTP/1.1\r\nHost: f\r\n\r\n" as *u8) 219 rn = cf_send(req, o, resp, 1048575) 220 ok = 1 221 if cf_has(resp, rn, "2d711642b726b044" as *u8) == 1 { ok = 0 } // pw hash prefix leaked 222 if cf_no_5xx(resp, rn) == 0 { ok = 0 } 223 rows = rows + 1; pass = pass + cf_row(5, ok, "path traversal does not leak pw hash" as *u8) 224 225 // A6: giant body on save (256KB) -> bounded, no crash 226 o = cf_cat(req, 0, "POST /admin/save HTTP/1.1\r\nHost: f\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 262144\r\n\r\n" as *u8) 227 fill = 0 228 while fill < 262144 { req[o] = 65 as u8; o = o + 1; fill = fill + 1 } 229 rn = cf_send(req, o, resp, 1048575) 230 ok = cf_no_5xx(resp, rn) 231 rows = rows + 1; pass = pass + cf_row(6, ok, "256KB save body contained" as *u8) 232 233 // A7: empty request (just CRLFCRLF) -> handled 234 o = cf_cat(req, 0, "\r\n\r\n" as *u8) 235 rn = cf_send(req, o, resp, 1048575) 236 ok = cf_no_5xx(resp, rn) 237 rows = rows + 1; pass = pass + cf_row(7, ok, "empty request contained" as *u8) 238 239 // A8: STILL ALIVE after the whole barrage (the real proof of graceful degradation) 240 ok = cf_alive(resp, 1048575) 241 rows = rows + 1; pass = pass + cf_row(8, ok, "daemon ALIVE after barrage" as *u8) 242 243 // ---- teardown + verdict row ---- 244 nxk_kill(pid, 9) 245 let stbuf: *i64 = sys_mmap(16) as *i64 246 sys_wait4(pid, stbuf, 0) 247 248 var verdict: i64 = 0 249 if pass == rows { verdict = 1 } 250 let line: *u8 = sys_mmap(256) 251 var lo: i64 = cf_cat(line, 0, "CMSGATE row=nx_cms_fuzz attacks=" as *u8) 252 let t2: *u8 = sys_mmap(24) 253 var mm: i64 = rows 254 var kk: i64 = 0 255 if mm == 0 { t2[0] = 48 as u8; kk = 1 } 256 while mm > 0 { t2[kk] = (48 + (mm % 10)) as u8; mm = mm / 10; kk = kk + 1 } 257 var ii: i64 = 0 258 while ii < kk { line[lo] = t2[kk-1-ii]; lo = lo + 1; ii = ii + 1 } 259 lo = cf_cat(line, lo, " contained=" as *u8) 260 mm = pass; kk = 0 261 if mm == 0 { t2[0] = 48 as u8; kk = 1 } 262 while mm > 0 { t2[kk] = (48 + (mm % 10)) as u8; mm = mm / 10; kk = kk + 1 } 263 ii = 0 264 while ii < kk { line[lo] = t2[kk-1-ii]; lo = lo + 1; ii = ii + 1 } 265 lo = cf_cat(line, lo, " verdict=" as *u8) 266 if verdict == 1 { lo = cf_cat(line, lo, "PASS" as *u8) } 267 if verdict == 0 { lo = cf_cat(line, lo, "FAIL" as *u8) } 268 line[lo] = 10 as u8 269 lo = lo + 1 270 sys_write(1, line, lo) 271 let gf: i64 = sys_openat_append("knowledge/status/cms_gate.log" as *u8, 0x1a4) 272 if gf >= 0 { sys_write(gf, line, lo); sys_close(gf) } 273 if verdict == 1 { sys_exit(0) } 274 sys_exit(1) 275 return 1 276}