#!/usr/bin/env python3 """Ph fuzz arm 1: malformed control frames against a live TCP supervisor, each refused by name. PLAN-PH-P34B-P7.md §1.1, row "gate: fuzz arm 1 malformed control frames": the mechanism was in the tree (Framing.h's latch, ControlInbox) and the end-to-end half was not. This is that half. It is registered as `TcpLane.ControlFrameFuzz` (label integration-tcp) and required PASSED by CI the way `TcpLane.SupervisorProtocolControls` is - a SKIP there is red. Five malformations, delivered at the two places a control frame is read: bad_identifier a well-formed Hello envelope finished with the file identifier 'XXXX' truncated_length_prefix five bytes of the eight-byte frame header, then the peer's write side closes oversize_length 'MGLF' and a length of 0xFFFFFFF0 garbage_flatbuffer a frame whose payload carries our identifier and a root offset that points nowhere, so only the verifier can refuse it wrong_union_type a VALID envelope of a type that is not expected there (a LogFlush as a first frame; a DataBind in an established session) (1) FIRST FRAME, read by the supervisor before it has forked anything (PH-7 (5)). Each must be answered with a Refuse frame naming the shape - never a bare close - with NO session child forked for it (/proc, and no reap line), and the supervisor must go on serving: after all of them, a well-formed client is welcomed and its session comes up. Before PH-7 (5) every one of these forked a child that `_exit(67)`ed without a word (or, for the oversize length, after the frame reader refused to allocate), counted as a faulted session. Three extra shapes ride along because they are cheap and each has its own words: a wrong magic, the 24-byte envelope whose tag says Hello and whose table is NULL, and a peer that says nothing at all. (2) STEADY STATE, read by an authenticated session child after Welcome. Each must end the session with a named line in the server log - `WireLogError`, since a Refuse is a handshake answer and the session is past its handshake - and the child must exit 0 (the peer's malformation is not the server's fault); the next client is then welcomed. Before this package two of the five (the truncated frame and the wrong union type) ended the session without a word. (3) SINGLE-SESSION SHAPE (no --serve), where the session process reads its own first frame through the same classification: a wrong identifier, a zero-length frame, a truncated header and a silent peer are each answered by name and the process exits 0, where it used to `_exit(67)` (or `_exit(0)`) without a word. And the one place RunSession's own token-first order is reachable: an unauthenticated Hello with a wrong layout or wire major is answered Refuse{Authentication} with nothing of ours in it. The first-frame phase also carries the two shapes the f2-auth fix round added: a zero-length frame, and a DataBind whose nonce is not 16 bytes (the supervisor's own named refusal). The supervisor runs with the authentication backoff OFF (MOBILEGL_IPC_AUTH_BACKOFF_AFTER=0): every malformation above is an authentication failure from 127.0.0.1, and with the default threshold the closing well-formed client would be backed off - which TcpLane.SupervisorProtocolControls' own `auth_backoff` control asserts on purpose, and which this arm is not about. Exit 77 (SKIP) without flatc or without the server image, like the supervisor smoke. """ import argparse import json import os from pathlib import Path import socket import struct import sys import tempfile import time sys.path.insert(0, str(Path(__file__).resolve().parent)) import tcp_supervisor_smoke as smoke # noqa: E402 (the shared helpers and the mirrored details) def framed(payload): return struct.pack('<4sI', b'MGLF', len(payload)) + payload def envelope(schema, flatbuffers, kind, build_body=None, identifier=b'MGLC'): """A framed CtrlEnvelope of union type `kind`; `build_body(builder)` returns its table, or None for the NULL-table shape.""" builder = flatbuffers.Builder(128) body = build_body(builder) if build_body else None root = schema['CtrlEnvelope'] root.Start(builder) root.AddMsgType(builder, kind) if body is not None: root.AddMsg(builder, body) builder.Finish(root.End(builder), identifier) return framed(bytes(builder.Output())) def log_flush(schema, flatbuffers): def body(builder): message = schema['LogFlush'] message.Start(builder) message.AddSeq(builder, 1) return message.End(builder) return envelope(schema, flatbuffers, schema['CtrlMsg'].CtrlMsg.LogFlush, body) def data_bind_envelope(schema, flatbuffers): def body(builder): vector = builder.CreateByteVector(os.urandom(16)) message = schema['DataBind'] message.Start(builder) message.AddNonce(builder, vector) return message.End(builder) return envelope(schema, flatbuffers, schema['CtrlMsg'].CtrlMsg.DataBind, body) def garbage_flatbuffer(): # Root offset 0xFFFFFF00 (far past the end), then OUR identifier: the identifier check passes # and the verifier is the one that has to say no. Deterministic, so a green run is not luck. return framed(struct.pack(' (bytes to send, close the write side afterwards).""" return { 'bad_identifier': (smoke.hello(schema, flatbuffers, fingerprint=fingerprint, identifier=b'XXXX'), False), 'truncated_length_prefix': (b'MGLF\x10', True), 'oversize_length': (b'MGLF' + struct.pack('/../flatc-build/flatc); skipping', flush=True) return smoke.kSkipExit if not os.path.isfile(args.server): print(f'ph_fuzz_control_frames: no server image at {args.server}; skipping', flush=True) return smoke.kSkipExit evidence = {'first_frame': {}, 'steady_state': {}} with tempfile.TemporaryDirectory(prefix='mgl-fuzz-schema-') as directory: flatbuffers, schema = smoke.load_schema(flatc, directory) knobs = {'MOBILEGL_IPC_AUTH_BACKOFF_AFTER': '0', 'MOBILEGL_IPC_PREAUTH_MS': '1500'} with smoke.supervisor(args.server, args.out / 'fuzz-supervisor.log', extra_env=knobs) as ( port, process, serverLog): fingerprint = smoke.learn_fingerprint(port, schema, flatbuffers)['expected'] # The child that answered it is reaped before the phase starts counting reaps (and its # reap line, written just after the waitpid, has landed). smoke.wait_for_no_session(process.pid) time.sleep(.2) # ---- (1) FIRST FRAME: the supervisor, before any fork --------------------------------- expected = { 'bad_identifier': (8, smoke.kDetailNoIdentifier), 'truncated_length_prefix': (8, smoke.kDetailTruncated), 'oversize_length': (8, smoke.kDetailNotAFrame), 'garbage_flatbuffer': (8, smoke.kDetailUnverifiable), 'wrong_union_type': (8, smoke.kDetailNotAHello), 'bad_magic': (8, smoke.kDetailNotAFrame), 'null_hello_table': (8, smoke.kDetailNotAHello), 'zero_length': (8, smoke.kDetailNoIdentifier), 'short_nonce_data_bind': (8, smoke.kDetailBadDataBind), 'silent': (4, smoke.kDetailDeadline), } cases = dict(malformations(schema, flatbuffers, fingerprint)) cases['bad_magic'] = (b'XXXX' + bytes(60), False) cases['null_hello_table'] = (envelope(schema, flatbuffers, schema['CtrlMsg'].CtrlMsg.Hello), False) cases['zero_length'] = (framed(b''), False) cases['short_nonce_data_bind'] = (smoke.data_bind(schema, flatbuffers, os.urandom(8)), False) cases['silent'] = (b'', False) reaped_before = reap_lines(serverLog) for name, (payload, half_close) in cases.items(): answer = send_first_frame(port, schema, payload, half_close) forked = smoke.child_pids(process.pid) code, detail = expected[name] assert answer.get('code') == code and answer.get('detail') == detail, ( f'first-frame {name}: expected Refuse code {code} "{detail}", got {answer}; see {serverLog}') assert not forked, f'first-frame {name}: the supervisor forked {forked} for it' assert process.poll() is None, f'first-frame {name} killed the supervisor' evidence['first_frame'][name] = answer text = serverLog.read_text(errors='replace') for name, (code, detail) in expected.items(): word = 'MalformedHello' if code == 8 else 'Authentication' assert f'Refuse{{{word}}} {detail}' in text, ( f'first-frame {name}: the supervisor log does not name "Refuse{{{word}}} {detail}"') assert reap_lines(serverLog) == reaped_before, ( 'a session child was reaped during the first-frame phase, so one was forked for a ' f'malformed peer; see {serverLog}') # A well-formed client after all of them: welcomed, and its session comes up. with smoke.held_session(port, schema, flatbuffers, smoke.hello(schema, flatbuffers, fingerprint=fingerprint)) as (control, welcome): assert welcome.get('welcome', 0), welcome ready = smoke.wait_for_log(serverLog, f'pid={welcome["welcome"]} transport=spawn role=server ready') assert ready is not None, f'the well-formed client after the first-frame phase never came up' evidence['first_frame']['then_well_formed'] = {'welcome': welcome['welcome']} # ---- (2) STEADY STATE: an authenticated session child, after Welcome --------------- steady = malformations(schema, flatbuffers, fingerprint) # A LogFlush is a type the steady-state loop DOES take, so the wrong union type there # is a DataBind - valid, verifiable, and not a message a session reads on control. steady['wrong_union_type'] = (data_bind_envelope(schema, flatbuffers), False) named = { 'bad_identifier': 'control frame carries no CtrlEnvelope identifier', 'truncated_length_prefix': 'control connection closed inside a frame', 'oversize_length': 'frame length 4294967280 exceeds', 'garbage_flatbuffer': 'control frame did not verify as a CtrlEnvelope', 'wrong_union_type': 'is not a LogFlush or SurfaceOp', } for name, (payload, half_close) in steady.items(): with smoke.held_session(port, schema, flatbuffers, smoke.hello(schema, flatbuffers, fingerprint=fingerprint)) as (control, welcome): assert welcome.get('welcome', 0), (name, welcome) child = welcome['welcome'] ready = smoke.wait_for_log(serverLog, f'pid={child} transport=spawn role=server ready') assert ready is not None, f'steady-state {name}: session pid={child} never came up' before = len(serverLog.read_text(errors='replace')) control.sendall(payload) if half_close: control.shutdown(socket.SHUT_WR) reaped = smoke.wait_for_log(serverLog, f'pid={child} reaped exit=') assert reaped is not None, f'steady-state {name}: session pid={child} did not end' said = reaped[before:] assert named[name] in said, ( f'steady-state {name}: session pid={child} ended without naming why ' f'(wanted "{named[name]}"); see {serverLog}') assert f'pid={child} reaped exit=0 ' in reaped, ( f'steady-state {name}: session pid={child} was counted as a fault') evidence['steady_state'][name] = next( l.strip() for l in said.splitlines() if named[name] in l) assert process.poll() is None, f'steady-state {name} killed the supervisor' # And the supervisor still serves. with smoke.held_session(port, schema, flatbuffers, smoke.hello(schema, flatbuffers, fingerprint=fingerprint)) as (control, welcome): assert welcome.get('welcome', 0), welcome ready = smoke.wait_for_log(serverLog, f'pid={welcome["welcome"]} transport=spawn role=server ready') assert ready is not None, 'the well-formed client after the steady-state phase never came up' evidence['steady_state']['then_well_formed'] = {'welcome': welcome['welcome']} # ---- (3) THE SINGLE-SESSION SHAPE: the process that IS the session reads its own first frame # # Without --serve there is no supervisor to read for it: RunSession reads the first frame # itself (the unix endpoint's children do the same), through the same classification. # That read used to `_exit(67)` on every malformation without a word; now the peer is # answered by name and the process exits 0 - the malformation is the peer's, not a fault. # # f2-auth fix round, two more kinds of case here: # - zero_length / truncated_length_prefix: the two first frames this read still ended on # with a bare `_exit(0)` - a complete frame of length 0, and a close inside the header. # RED before the fix: RuntimeError('control channel closed before a complete frame'). # - unauthenticated_*: THE TOKEN IS ASKED BEFORE THE WIRE HERE TOO (PH-7 (5), ph-f.md # §6.4 (b)). On TCP --serve the supervisor refuses these before any fork, so the # supervisor smoke's unauthenticated controls never reach RunSession's own order; this # shape is the one that does (and the unix endpoint runs the same lines). A Hello with a # wrong token AND fingerprint 0 (or wire major 99) must be answered # Refuse{Authentication} carrying nothing of ours. RED with ValidatePeerHandshake moved # back before AuthenticatePeerToken: code 2 with our fingerprint in `expected` (code 1 # with our version). evidence['single_session'] = {} for name, payload, half_close, code, detail in ( ('bad_identifier', smoke.hello(schema, flatbuffers, fingerprint=fingerprint, identifier=b'XXXX'), False, 8, smoke.kDetailNoIdentifier), ('zero_length', framed(b''), False, 8, smoke.kDetailNoIdentifier), ('truncated_length_prefix', b'MGLF\x10', True, 8, smoke.kDetailTruncated), ('unauthenticated_wrong_layout', smoke.hello(schema, flatbuffers, fingerprint=0, token='wrong-token'), False, 4, 'token mismatch'), ('unauthenticated_wire_major_99', smoke.hello(schema, flatbuffers, fingerprint=fingerprint, major=99, token='wrong-token'), False, 4, 'token mismatch'), ('silent', b'', False, 4, smoke.kDetailDeadline)): with smoke.supervisor(args.server, args.out / f'single-session-{name}.log', serve=False, extra_env=knobs) as (port, process, serverLog): answer = send_first_frame(port, schema, payload, half_close) try: exited = process.wait(timeout=10) except Exception: # noqa: BLE001 - still running is the red shape here exited = None assert answer.get('code') == code and answer.get('detail') == detail, ( f'single-session {name}: expected Refuse code {code} "{detail}", got {answer}; see {serverLog}') assert answer.get('expected') == 0, ( f'single-session {name}: the refusal carried {answer.get("expected")} in `expected`; ' f'our fingerprint is {fingerprint}') assert exited == 0, f'single-session {name}: the session process exited {exited}, not 0' evidence['single_session'][name] = {'refusal': answer, 'exit': exited} (args.out / 'fuzz-control-frames.json').write_text(json.dumps(evidence, indent=2)) print(json.dumps(evidence, indent=2)) return 0 if __name__ == '__main__': sys.exit(main())