mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-11-07 01:53:24 +09:00
312 lines
18 KiB
Python
312 lines
18 KiB
Python
#!/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('<I', 0xFFFFFF00) + b'MGLC' + bytes(range(56)))
|
|
|
|
|
|
def malformations(schema, flatbuffers, fingerprint):
|
|
"""name -> (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('<I', 0xFFFFFFF0) + bytes(56), False),
|
|
'garbage_flatbuffer': (garbage_flatbuffer(), False),
|
|
'wrong_union_type': (log_flush(schema, flatbuffers), False),
|
|
}
|
|
|
|
|
|
def send_first_frame(port, schema, payload, half_close):
|
|
"""One connection, one malformed first frame, and whatever the supervisor answers."""
|
|
peer = smoke.connect_control(port)
|
|
try:
|
|
peer.settimeout(10)
|
|
if payload:
|
|
peer.sendall(payload)
|
|
if half_close:
|
|
peer.shutdown(socket.SHUT_WR)
|
|
began = time.monotonic()
|
|
try:
|
|
answer = smoke.receive(peer, schema)
|
|
except (RuntimeError, OSError) as error:
|
|
answer = {'error': repr(error)}
|
|
answer['seconds'] = round(time.monotonic() - began, 3)
|
|
return answer
|
|
finally:
|
|
peer.close()
|
|
|
|
|
|
def reap_lines(path):
|
|
return path.read_text(errors='replace').count(' reaped ') if path.is_file() else 0
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--server', required=True)
|
|
parser.add_argument('--flatc', default='')
|
|
parser.add_argument('--out', type=Path, required=True)
|
|
args = parser.parse_args()
|
|
args.out.mkdir(parents=True, exist_ok=True)
|
|
flatc = smoke.resolve_flatc(args.flatc)
|
|
if flatc is None:
|
|
print('ph_fuzz_control_frames: no flatc found (--flatc, $MOBILEGL_FLATC_EXECUTABLE, '
|
|
'<repo>/../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())
|