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}