[MG_Remote, MG_IntegrationTest, CI, scripts] (P7): add Ph fuzz arm 1 for malformed control frames

This commit is contained in:
2026-09-23 13:45:00 -04:00
parent 7bb1da73d4
commit 3d7cc9592b
8 changed files with 326 additions and 5 deletions
+3 -1
View File
@@ -1482,6 +1482,8 @@ jobs:
# the supervisor's Busy / token / listen / version / build-policy / reap controls zero times.
# --check is passed so this step cannot quietly regenerate a different committed header;
# keeping that honest is flatc-check's job and this step must not overlap with it.
# P7 F2: `TcpLane.ControlFrameFuzz` (Ph fuzz arm 1, scripts/ci/ph_fuzz_control_frames.py)
# needs the same flatc and is required PASSED by name on the same junit_tally line.
- name: Check out FlatBuffers (TCP lane)
run: git submodule update --init 3rdparty/flatbuffers
@@ -1516,7 +1518,7 @@ jobs:
ctest --show-only=json-v1 -L '^integration-tcp$' > "${RUNNER_TEMP}/tcp-discovery.json"
ctest --output-on-failure -L '^integration-tcp$' --no-tests=error -j 4 --output-junit "${RUNNER_TEMP}/tcp-baseline.xml"
python3 ../scripts/ci/junit_tally.py "${RUNNER_TEMP}/tcp-baseline.xml" --require-tcp-ran --require-run-ahead --discovery "${RUNNER_TEMP}/tcp-discovery.json"
python3 ../scripts/ci/junit_tally.py "${RUNNER_TEMP}/tcp-baseline.xml" --require-entry-passed TcpLane.SupervisorProtocolControls --require-entry-passed TcpLane.EventForfeitPeer
python3 ../scripts/ci/junit_tally.py "${RUNNER_TEMP}/tcp-baseline.xml" --require-entry-passed TcpLane.SupervisorProtocolControls --require-entry-passed TcpLane.EventForfeitPeer --require-entry-passed TcpLane.ControlFrameFuzz
# Both full transport runs are hard gates. The per-case comparison below
# remains diagnostic because explicit mechanism controls retain their own mode.
@@ -2129,6 +2129,20 @@ if (MOBILEGL_BUILD_DISAGGREGATED)
LABELS integration-tcp TIMEOUT 120
ENVIRONMENT "${MGL_ITEST_EVENT_FORFEIT_ENVIRONMENT}")
endif()
# P7 F2: Ph fuzz arm 1 (PLAN-PH-P34B-P7.md §1.1, "gate: fuzz arm 1 malformed control
# frames"). Five malformed control frames - bad identifier, truncated length prefix, oversize
# length, garbage flatbuffer, wrong union type - sent to a live supervisor as a FIRST frame
# (answered by name with no child forked, PH-7 (5)) and inside an established session (the
# session ends with a named line and is not counted as a fault), with a well-formed client
# welcomed after each phase. Same shape as TcpLane.SupervisorProtocolControls: its own supervisor on its own
# port, the lane's resource lock, flatc or SKIP (77) - and CI requires it PASSED by name.
add_test(NAME TcpLane.ControlFrameFuzz COMMAND "${Python3_EXECUTABLE}"
"${CMAKE_SOURCE_DIR}/scripts/ci/ph_fuzz_control_frames.py"
--server "${MGL_ITEST_SPLIT_SERVER_PATH}"
--out "${CMAKE_CURRENT_BINARY_DIR}/tcp-control-frame-fuzz")
set_tests_properties(TcpLane.ControlFrameFuzz PROPERTIES
LABELS integration-tcp TIMEOUT 180 SKIP_RETURN_CODE 77
RESOURCE_LOCK mobilegl-tcp)
# D2: the texture-view opt-in on both TCP lists too, so the three shared GLES split arms
# carry one configuration. See the note over MGL_ITEST_GLES_SPLIT_ENVIRONMENT.
#
+17 -1
View File
@@ -420,6 +420,14 @@ void SendLogAck(void* pointer) {
// below, so an apply thread that the stop wakes inside ReserveEventOrBlock names the
// hangup (PeerGone) rather than the stop (PH-6 fix round).
session.NoteControlStreamEnded();
// Fuzz arm 1, steady state. A bad magic or an over-cap length has already been named
// by the framing latch (Framing.h) as it latched; a clean close is the client's normal
// end and says nothing. The one silent case was a close INSIDE a frame - a truncated
// length prefix or payload - which ended the session exactly like a clean close.
if (result == MOBILEGL_ERR_TRANSPORT_CLOSED && control->BufferedBytes() != 0)
WireLogError("MG_Remote server: control connection closed inside a frame (%llu bytes of it "
"received); ending the session",
static_cast<unsigned long long>(control->BufferedBytes()));
break;
}
// THE FILE IDENTIFIER, ASKED FIRST AND SAID OUT LOUD (P7 wave 0).
@@ -478,7 +486,15 @@ void SendLogAck(void* pointer) {
continue;
}
const auto* operation = envelope->msg_as_SurfaceOp();
if (!operation) break;
if (!operation) {
// Fuzz arm 1, steady state: a well-formed envelope of a type this loop does not take
// (or a SurfaceOp tag with no table behind it) ended the session without a word.
WireLogError("MG_Remote server: control frame of type %u (%s) is not a LogFlush or SurfaceOp "
"(size=%llu); ending the session",
static_cast<unsigned>(envelope->msg_type()), Protocol::EnumNameCtrlMsg(envelope->msg_type()),
static_cast<unsigned long long>(size));
break;
}
Server::SurfaceControlFrame reply{};
(void)ServerApplyWireSurfaceOp(*operation, &reply);
// PH-1 (3): an op that latched (its own bytes, or a record the apply thread was on) is
@@ -777,6 +777,11 @@ namespace MobileGL::MG_Remote::Transport {
return m_reader.PendingMessageSize();
}
std::uint64_t SocketTransport::BufferedBytes() {
std::lock_guard<std::mutex> lock(m_recvMutex);
return m_reader.BufferedBytes();
}
MobileGLResult SocketTransport::ShareFd(int fd, MobileGLByteSpan sideband) {
if (m_tcp || m_auxFd < 0) {
// A link that declared no descriptor passing. Rule G: it does not
@@ -843,6 +848,7 @@ namespace MobileGL::MG_Remote::Transport {
return MOBILEGL_ERR_UNSUPPORTED;
}
std::uint64_t SocketTransport::PeekFrameSize() { return 0; }
std::uint64_t SocketTransport::BufferedBytes() { return 0; }
MobileGLResult SocketTransport::ShareFd(int, MobileGLByteSpan) {
return MOBILEGL_ERR_UNSUPPORTED;
}
@@ -167,6 +167,10 @@ namespace MobileGL::MG_Remote::Transport {
void CloseLocalCopy();
// End requests while retaining the receive side for final diagnostics/EOF.
MobileGLResult ShutdownSend();
// Bytes received but not yet returned as a whole frame. Non-zero after a TRANSPORT_CLOSED
// means the peer closed INSIDE a frame, which the steady-state control loop names (fuzz
// arm 1) rather than treating it as the client's normal end.
std::uint64_t BufferedBytes();
private:
// Pulls whatever the socket has into the reassembler. Returns
+2 -2
View File
@@ -11,8 +11,8 @@
// These are the cheap halves. The end-to-end halves - a live supervisor that must answer every
// malformed first frame by name, fork for nothing it has not authenticated, refuse a silent peer
// at its deadline, cap its pending connections and back an address off - are
// TcpLane.SupervisorProtocolControls (scripts/ci/tcp_supervisor_smoke.py). What is pinned here
// is what
// TcpLane.SupervisorProtocolControls (scripts/ci/tcp_supervisor_smoke.py) and
// TcpLane.ControlFrameFuzz (scripts/ci/ph_fuzz_control_frames.py). What is pinned here is what
// those scripts cannot see from outside: that each malformation lands on the shape it is named
// for (and not merely on SOME refusal), that the assembler never reads a byte past the first
// frame, and the backoff's arithmetic.
+278
View File
@@ -0,0 +1,278 @@
#!/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 and a silent peer are each answered by
name and the process exits 0, where it used to `_exit(67)` without a word.
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),
'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['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.
evidence['single_session'] = {}
for name, payload, code, detail in (
('bad_identifier', smoke.hello(schema, flatbuffers, fingerprint=fingerprint, identifier=b'XXXX'),
8, smoke.kDetailNoIdentifier),
('silent', b'', 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, False)
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 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())
+2 -1
View File
@@ -17,7 +17,8 @@ P7 F2, PH-7 (5): the supervisor now reads and authenticates every first frame be
the controls here assert that an unauthenticated peer learns nothing of ours
(`unauthenticated_wrong_layout`), that a silent one costs no fork and is answered at the pre-auth
deadline (`silent_peer`), that Busy is the answer to an AUTHENTICATED second client (`busy`), the
pending cap and the backoff (`pending_cap`, `auth_backoff`).
pending cap and the backoff (`pending_cap`, `auth_backoff`). The five malformed-frame shapes
are fuzz arm 1's own entry, scripts/ci/ph_fuzz_control_frames.py.
It needs flatc, which is deliberately absent from the default build graph (gen_protocol.py's
header block says why). Resolution order is gen_protocol.py's, minus the build-it-for-you arm: