diff --git a/.github/scripts/tibia-official-client-re-p2-buffer-downstream-consumer.py b/.github/scripts/tibia-official-client-re-p2-buffer-downstream-consumer.py new file mode 100644 index 0000000000..e3dfcae4d2 --- /dev/null +++ b/.github/scripts/tibia-official-client-re-p2-buffer-downstream-consumer.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import struct +import subprocess +import sys +from pathlib import Path + +EXPECTED_SHA256 = "e6c244bd39fe2e0632f6f000efd3147164696efa8e901718668e0442325ff7fe" +EXPECTED_SIZE = 51965216 + +# Coordinator-promoted #308 exact type / retained-writer fence. +WRITER_AP = 0x2F69DD0 +WRITER_RTTI = 0x3080728 +IODEVICE_WRITER_AP = 0x2F69D48 +IODEVICE_WRITER_RTTI = 0x3080718 +INTERMEDIATE_AP = 0x2F69E30 +INTERMEDIATE_RTTI = 0x3080748 + +# Exact setup / processor graph on the fenced client. +CLIENT_PROCESSOR_AP = 0x2F6A208 +RAW_PROCESSOR_AP = 0x2F6A230 +CLIENT_PROCESSOR_ENTRY = 0xC2DF80 +RAW_PROCESSOR_ENTRY = 0xB47130 +CONNECTION_INVOKER = 0x7DD630 +DUAL_PRECONDITION = 0xB40370 +DUAL_ENTRY_80 = 0xB56D60 +DUAL_ENTRY_78 = 0xB56970 + + +def require(value: bool, marker: str) -> None: + if not value: + print(f"P2_DOWNSTREAM_FAIL={marker}", file=sys.stderr) + raise SystemExit(2) + print(f"P2_DOWNSTREAM_OK={marker}") + + +class Elf64: + def __init__(self, path: Path) -> None: + self.data = path.read_bytes() + require(self.data[:4] == b"\x7fELF", "elf_magic") + require(self.data[4] == 2 and self.data[5] == 1, "elf64_little_endian") + phoff = struct.unpack_from(" int: + for vaddr, memsz, offset, _flags in self.loads: + if vaddr <= va and va + size <= vaddr + memsz: + out = offset + (va - vaddr) + require(out + size <= len(self.data), f"file_backed_{va:x}") + return out + raise ValueError(f"unmapped VA 0x{va:x}") + + def read(self, va: int, size: int) -> bytes: + off = self.file_offset(va, size) + return self.data[off : off + size] + + def u64(self, va: int) -> int: + return struct.unpack(" bool: + for vaddr, memsz, _offset, flags in self.loads: + if vaddr <= va < vaddr + memsz: + return bool(flags & 1) + return False + + +def sha256(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def disassemble(objdump: Path, client: Path, lo: int, hi: int) -> str: + p = subprocess.run( + [ + str(objdump), "-d", "-Mintel", "--no-show-raw-insn", + f"--start-address=0x{lo:x}", f"--stop-address=0x{hi:x}", str(client), + ], + check=True, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=60, + ) + return p.stdout + + +def instruction_map(text: str) -> dict[int, str]: + out: dict[int, str] = {} + for line in text.splitlines(): + m = re.match(r"^\s*([0-9a-f]+):\s*(.*)$", line, re.I) + if m: + out[int(m.group(1), 16)] = m.group(2).strip() + return out + + +def at(insns: dict[int, str], addr: int, *needles: str) -> bool: + line = insns.get(addr, "").lower() + return bool(line) and all(n.lower() in line for n in needles) + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--client", type=Path, required=True) + p.add_argument("--objdump", type=Path, required=True) + p.add_argument("--output-json", type=Path, required=True) + p.add_argument("--output-text", type=Path, required=True) + p.add_argument("--evidence", type=Path, required=True) + args = p.parse_args() + + require(args.client.is_file(), "client_present") + require(args.objdump.is_file(), "objdump_present") + require(args.client.stat().st_size == EXPECTED_SIZE, "exact_client_size") + digest = sha256(args.client) + require(digest == EXPECTED_SHA256, "exact_client_sha256") + elf = Elf64(args.client) + + # Revalidate promoted type anchors and correct the false-positive writer vtable bleed from run #1. + for ap, rtti, name in ( + (WRITER_AP, WRITER_RTTI, "writer"), + (IODEVICE_WRITER_AP, IODEVICE_WRITER_RTTI, "iodevice_writer"), + (INTERMEDIATE_AP, INTERMEDIATE_RTTI, "intermediate"), + ): + require(elf.u64(ap - 16) == 0, f"{name}_offset_to_top_zero") + require(elf.u64(ap - 8) == rtti, f"{name}_rtti") + require([elf.u64(WRITER_AP + i * 8) for i in range(5)] == [0x7E3C10, 0x7E3CA0, 0xC262B0, 0xC21EC0, 0], "writer_real_vtable_boundary") + require(elf.u64(WRITER_AP + 5 * 8) == 0x3080738, "writer_neighbor_rtti_not_slot") + require(elf.u64(WRITER_AP + 8 * 8) == 0xD114C0, "historical_bleed_address_present_not_writer_slot") + + # Exact processor vtable identities: this turns the invoker's indirect calls into typed edges. + require(elf.u64(CLIENT_PROCESSOR_AP + 0x10) == CLIENT_PROCESSOR_ENTRY, "client_processor_virtual_plus10") + require(elf.u64(RAW_PROCESSOR_AP + 0x10) == RAW_PROCESSOR_ENTRY, "raw_processor_virtual_plus10") + + setup = disassemble(args.objdump, args.client, 0x1970C80, 0x19710B5) + setup_i = instruction_map(setup) + + # Persistent QBuffer object provenance from #308: actual QBuffer object is r15 = allocation+0x10. + require(at(setup_i, 0x1970C96, "lea", "r15,[rax+0x10]"), "persistent_qbuffer_object_pointer") + require(at(setup_i, 0x1970CAD, "call", "QBufferC2"), "persistent_qbuffer_constructor") + require(at(setup_i, 0x1970CC6, "call", "QBuffer4open"), "persistent_qbuffer_open") + require(at(setup_i, 0x1970CA6, "[rbp-0x218]", "r15"), "persistent_qbuffer_saved_rbpm218") + + # TProtocolClientMessageProcessor actual object begins at allocation+0x10. Its this+0x18 + # receives the SAME saved persistent QBuffer object from rbp-0x218. GNU objdump renders + # RIP-relative comment targets without guaranteeing an optional "0x" prefix. + require(at(setup_i, 0x197104F, "lea", "rdx,[rax+0x10]"), "client_processor_actual_object_pointer") + require(at(setup_i, 0x1971056, "2f6a208"), "client_processor_ap_loaded") + require(at(setup_i, 0x197105D, "[rax+0x10]", "rcx"), "client_processor_vptr_store") + require(at(setup_i, 0x1971084, "rsi", "[rbp-0x218]"), "client_processor_reloads_same_qbuffer") + require(at(setup_i, 0x197108F, "[rax+0x28]", "rsi"), "client_processor_this_plus18_qbuffer_store") + require(at(setup_i, 0x19710A7, "[rcx+0xa00]", "rdx"), "outer_retains_client_processor") + + invoker = disassemble(args.objdump, args.client, 0x7DD630, 0x7DD720) + inv_i = instruction_map(invoker) + client_proc = disassemble(args.objdump, args.client, 0xC2DF80, 0xC2E080) + cp_i = instruction_map(client_proc) + raw_proc = disassemble(args.objdump, args.client, 0xB47130, 0xB47320) + rp_i = instruction_map(raw_proc) + + # Exact invoker pipeline. rbp=rsp is the same stack message object across all downstream calls. + require(at(inv_i, 0x7DD66C, "mov", "rbp,rsp"), "invoker_message_object_is_rsp") + require(at(inv_i, 0x7DD66F, "rdx,r12"), "invoker_signal_argument_to_client_processor") + require(at(inv_i, 0x7DD672, "rdi,rbp"), "invoker_client_processor_sret_message") + require(at(inv_i, 0x7DD675, "rsi", "[rax+0xa00]"), "invoker_client_processor_this") + require(at(inv_i, 0x7DD67F, "call", "[rax+0x10]"), "invoker_calls_client_processor_plus10") + require(at(inv_i, 0x7DD686, "rsi,rbp"), "invoker_same_message_to_raw_processor") + require(at(inv_i, 0x7DD689, "rdi", "[rax+0xa10]"), "invoker_raw_processor_this") + require(at(inv_i, 0x7DD693, "call", "[rax+0x10]"), "invoker_calls_raw_processor_plus10") + require(at(inv_i, 0x7DD69A, "rsi,rbp"), "invoker_same_message_to_dual_plus80") + require(at(inv_i, 0x7DD69D, "rdi", "[rax+0xc18]"), "invoker_dual_this_plus80") + require(at(inv_i, 0x7DD6A7, "call", "[rax+0x80]"), "invoker_calls_dual_plus80") + require(at(inv_i, 0x7DD6B1, "rsi,rbp"), "invoker_same_message_to_dual_plus78") + require(at(inv_i, 0x7DD6B4, "rdi", "[rax+0xc18]"), "invoker_dual_this_plus78") + require(at(inv_i, 0x7DD6BE, "call", "[rax+0x78]"), "invoker_calls_dual_plus78") + + # Client processor exact ABI/data flow: (sret message, this, signal arg). + require(at(cp_i, 0xC2DF86, "r12,rdx"), "client_processor_captures_signal_arg") + require(at(cp_i, 0xC2DF8A, "rbp,rsi"), "client_processor_captures_this") + require(at(cp_i, 0xC2DF8E, "rbx,rdi"), "client_processor_captures_sret_message") + require(at(cp_i, 0xC2DF95, "rdi", "[rsi+0x8]"), "client_processor_retained_intermediate_this") + require(at(cp_i, 0xC2DF99, "rsi,rdx"), "client_processor_signal_to_intermediate") + require(at(cp_i, 0xC2DFA2, "call", "[rax+0x10]"), "client_processor_invokes_retained_intermediate") + + # This is the first exact consumer of the promoted persistent QBuffer: same this+0x18 pointer + # established above is passed to QIODevice::readAll(). + require(at(cp_i, 0xC2DFA5, "rdi", "[rbp+0x18]"), "client_processor_reads_persistent_qbuffer_member") + require(at(cp_i, 0xC2DFD5, "call", "QIODevice7readAll"), "persistent_qbuffer_qiodevice_readall") + require(at(cp_i, 0xC2DFEB, "lea", "rbp,[rbx+0x8]"), "client_processor_output_qbytearray_field") + require(at(cp_i, 0xC2E012, "call", "QByteArrayaSERKS"), "client_processor_assigns_qbytearray_output") + require(at(cp_i, 0xC2E040, "rax,rbx"), "client_processor_returns_message_object") + + # RawDataProcessor consumes the SAME message object and transforms its QByteArray at +0x8 in place. + require(at(rp_i, 0xB47132, "lea", "rax,[rsi+0x8]"), "raw_processor_message_qbytearray_pointer") + require(at(rp_i, 0xB47151, "[rsp+0x8]", "rax"), "raw_processor_saves_input_qbytearray_pointer") + require(at(rp_i, 0xB47189, "call", "QByteArray6insert"), "raw_processor_qbytearray_insert") + require(at(rp_i, 0xB47206, "call", "QByteArray6append"), "raw_processor_qbytearray_append") + require(at(rp_i, 0xB47287, "[r12+0x28]"), "raw_processor_reads_same_message_state") + require(at(rp_i, 0xB472F8, "rdi", "[rsp+0x8]"), "raw_processor_reloads_input_qbytearray_pointer") + require(at(rp_i, 0xB47300, "call", "QByteArrayaSERKS"), "raw_processor_assigns_transformed_qbytearray_in_place") + + result = { + "schema_version": 3, + "exact_client": { + "sha256": digest, + "size": EXPECTED_SIZE, + "version_mapping": "15.32.df7b29", + "platform": "official_native_linux_only", + }, + "false_positive_correction": { + "tprotocolwriter_real_slots": ["0x7e3c10", "0x7e3ca0", "0xc262b0", "0xc21ec0"], + "tprotocolwriter_slot4": "0x0", + "0xd114c0_is_not_tprotocolwriter_slot8": True, + "run_31904191629_fixed_window_candidates_rejected": True, + }, + "provenance": { + "persistent_qbuffer_saved_at_setup_scratch": "rbp-0x218", + "persistent_qbuffer_stored_in_client_processor_member": "this+0x18", + "client_processor_outer_member": "outer+0xa00", + "raw_processor_outer_member": "outer+0xa10", + "dualconnection_outer_member": "outer+0xc18", + }, + "stage_order": [ + { + "stage": "TProtocolClientMessageProcessor virtual +0x10", + "entry": "0xc2df80", + "input": "signal argument", + "effect": "invoke retained intermediate, then QIODevice::readAll on exact persistent QBuffer at this+0x18, assign bytes to output QByteArray at message+0x8", + }, + { + "stage": "TGameserverNetworkPacketRawDataProcessor virtual +0x10", + "entry": "0xb47130", + "input": "same message object", + "effect": "QByteArray insert/append/reallocation path and in-place assignment back to message+0x8", + }, + { + "stage": "TGameserverDualConnection virtual +0x80", + "entry": "0xb56d60", + "input": "same post-raw message object", + "effect": "consumer call proven; transport semantics not classified here", + }, + { + "stage": "TGameserverDualConnection virtual +0x78", + "entry": "0xb56970", + "input": "same post-raw message object", + "effect": "consumer call proven; transport semantics not classified here", + }, + ], + "classification": { + "persistent_qbuffer_direct_readall": "PROVEN", + "first_downstream_consumer": "PROVEN:TProtocolClientMessageProcessor+0x10@0xc2df80", + "first_downstream_transform": "PROVEN:TGameserverNetworkPacketRawDataProcessor+0x10@0xb47130", + "same_message_handoff_to_dualconnection": "PROVEN", + "protocol_stage_order": "PROVEN_PARTIAL", + "framing": "UNKNOWN", + "sequence": "UNKNOWN", + "compression": "UNKNOWN", + "encryption": "UNKNOWN", + "final_binary_egress": "UNKNOWN", + "causal_local_harness": "UNKNOWN", + }, + "negative_controls": { + "generic_qiodevice_census_used_as_proof": False, + "generic_qbuffer_census_used_as_proof": False, + "vtable_adjacency_used_as_temporal_proof": False, + "historical_final_socket_run_used_as_proof": False, + "direct_dualconnection_writer_ownership_assumed": False, + "dual_plus80_or_plus78_labeled_final_egress": False, + "raw_byte_transform_labeled_framing_without_semantics": False, + }, + "semantic_result": "POST_SERIALIZATION_PROCESSOR_CHAIN_PROVEN", + } + + args.output_json.parent.mkdir(parents=True, exist_ok=True) + args.output_json.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8") + args.output_text.write_text( + "\n".join( + [ + "P2_DOWNSTREAM_RESULT=POST_SERIALIZATION_PROCESSOR_CHAIN_PROVEN", + f"CLIENT_SHA256={digest}", + f"CLIENT_SIZE={EXPECTED_SIZE}", + "TPROTOCOLWRITER_SLOT_BOUNDARY=PROVEN_SLOT4_ZERO", + "PERSISTENT_QBUFFER_DIRECT_READALL=PROVEN", + "FIRST_DOWNSTREAM_CONSUMER=PROVEN:TProtocolClientMessageProcessor+0x10@0xc2df80", + "FIRST_DOWNSTREAM_TRANSFORM=PROVEN:TGameserverNetworkPacketRawDataProcessor+0x10@0xb47130", + "SAME_MESSAGE_HANDOFF_TO_DUALCONNECTION=PROVEN", + "PROTOCOL_STAGE_ORDER=PROVEN_PARTIAL", + "PROTOCOL_FRAMING=UNKNOWN", + "SEQUENCE=UNKNOWN", + "COMPRESSION=UNKNOWN", + "ENCRYPTION=UNKNOWN", + "FINAL_BINARY_EGRESS=UNKNOWN", + "CAUSAL_LOCAL_HARNESS=UNKNOWN", + ] + ) + "\n", + encoding="utf-8", + ) + + evidence = [ + "# Persistent QBuffer -> ClientMessageProcessor setup", + *[f"{a:x}: {setup_i[a]}" for a in sorted(setup_i) if a in { + 0x1970C96,0x1970CA6,0x1970CAD,0x1970CC6,0x197104F,0x1971056,0x197105D, + 0x1971084,0x197108F,0x19710A7, + }], + "", + "# Exact invoker stage order", + *[f"{a:x}: {inv_i[a]}" for a in sorted(inv_i)], + "", + "# TProtocolClientMessageProcessor exact downstream read", + *[f"{a:x}: {cp_i[a]}" for a in sorted(cp_i)], + "", + "# TGameserverNetworkPacketRawDataProcessor exact in-place transform", + *[f"{a:x}: {rp_i[a]}" for a in sorted(rp_i)], + ] + args.evidence.write_text("\n".join(evidence) + "\n", encoding="utf-8") + + print("P2_DOWNSTREAM_COMPLETE=true") + print("P2_DOWNSTREAM_RESULT=POST_SERIALIZATION_PROCESSOR_CHAIN_PROVEN") + print("P2_DOWNSTREAM_PERSISTENT_QBUFFER_DIRECT_READALL=PROVEN") + print("P2_DOWNSTREAM_FIRST_CONSUMER=PROVEN") + print("P2_DOWNSTREAM_FIRST_TRANSFORM=PROVEN") + print("P2_DOWNSTREAM_SAME_MESSAGE_TO_DUALCONNECTION=PROVEN") + print("P2_DOWNSTREAM_PROTOCOL_STAGE_ORDER=PROVEN_PARTIAL") + print("P2_DOWNSTREAM_PROTOCOL_FRAMING=UNKNOWN") + print("P2_DOWNSTREAM_FINAL_BINARY_EGRESS=UNKNOWN") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/.github/workflows/tibia-official-client-re-p2-buffer-downstream-consumer.yml b/.github/workflows/tibia-official-client-re-p2-buffer-downstream-consumer.yml new file mode 100644 index 0000000000..4a8dd149f8 --- /dev/null +++ b/.github/workflows/tibia-official-client-re-p2-buffer-downstream-consumer.yml @@ -0,0 +1,181 @@ +name: Track A P2 buffer downstream consumer + +on: + push: + branches: [research/OTC-20260815-track-a-p2-buffer-downstream-consumer] + paths: + - .github/workflows/tibia-official-client-re-p2-buffer-downstream-consumer.yml + - .github/scripts/tibia-official-client-re-p2-buffer-downstream-consumer.py + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: track-a-p2-buffer-downstream-consumer-${{ github.ref }} + cancel-in-progress: true + +jobs: + hardened-post-serialization-chain: + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + EXPECTED_CLIENT_SHA256: e6c244bd39fe2e0632f6f000efd3147164696efa8e901718668e0442325ff7fe + EXPECTED_CLIENT_SIZE: '51965216' + EXPECTED_HEAD_BRANCH: research/OTC-20260815-track-a-p2-buffer-downstream-consumer + OFFICIAL_LINUX_ARCHIVE_URL: https://static.tibia.com/download/tibia.x64.tar.gz + steps: + - name: Checkout exact Draft head + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Verify hosted no-runtime execution boundary + shell: bash + run: | + set -Eeuo pipefail + [[ "$GITHUB_REPOSITORY" == blakinio/otclient ]] + [[ "$GITHUB_REF_NAME" == "$EXPECTED_HEAD_BRANCH" ]] + [[ "$RUNNER_OS" == Linux ]] + [[ "$RUNNER_ARCH" == X64 ]] + [[ "${RUNNER_ENVIRONMENT:-}" == github-hosted ]] + python3 -m py_compile .github/scripts/tibia-official-client-re-p2-buffer-downstream-consumer.py + objdump="$(command -v objdump)" + [[ -x "$objdump" ]] + "$objdump" --version | head -n 1 + echo "TRACK_A_P2_OBJDUMP=$objdump" >> "$GITHUB_ENV" + echo "TRACK_A_P2_RUNTIME_ACCESS=none" + echo "TRACK_A_P2_PERSISTENT_SESSION=none" + echo "TRACK_A_P2_PHYSICAL_E2E=false" + echo "TRACK_A_P2_SYNOLOGY_EXECUTION=false" + + - name: Materialize exact official Linux client without executing it + shell: bash + run: | + set -Eeuo pipefail + out=artifacts/p2-buffer-downstream-consumer + mkdir -p "$out" + archive="$RUNNER_TEMP/tibia.x64.tar.gz" + client="$RUNNER_TEMP/tibia-static-client" + + if ! curl --fail --silent --show-error --location \ + --retry 2 --retry-all-errors --connect-timeout 20 --max-time 900 \ + --user-agent 'blakinio-otclient-static-research/1.0' \ + "$OFFICIAL_LINUX_ARCHIVE_URL" --output "$archive"; then + { + echo 'P2_HOSTED_INPUT_STATUS=INPUT_BLOCKED' + echo 'P2_HOSTED_INPUT_REASON=official_linux_archive_download_failed' + echo "P2_HOSTED_INPUT_URL=$OFFICIAL_LINUX_ARCHIVE_URL" + } | tee "$out/input-status.txt" + exit 3 + fi + + python3 - "$archive" "$client" <<'PY' + import shutil + import sys + import tarfile + from pathlib import PurePosixPath + + archive, target = sys.argv[1:] + with tarfile.open(archive, mode='r:*') as tf: + candidates = [] + for member in tf.getmembers(): + if not member.isfile(): + continue + path = PurePosixPath(member.name) + if path.is_absolute() or '..' in path.parts: + continue + parts = path.parts + if len(parts) >= 2 and parts[-2:] == ('bin', 'client'): + candidates.append(member) + if len(candidates) != 1: + names = ','.join(m.name for m in candidates[:10]) or '' + raise SystemExit(f'P2_HOSTED_INPUT_BLOCKED=client_member_count:{len(candidates)}:{names}') + src = tf.extractfile(candidates[0]) + if src is None: + raise SystemExit('P2_HOSTED_INPUT_BLOCKED=client_member_unreadable') + with src, open(target, 'wb') as dst: + shutil.copyfileobj(src, dst, length=1024 * 1024) + print(f'P2_HOSTED_CLIENT_MEMBER={candidates[0].name}') + PY + + size="$(stat -c '%s' "$client")" + sha="$(sha256sum "$client" | awk '{print $1}')" + { + echo "P2_HOSTED_INPUT_SIZE=$size" + echo "P2_HOSTED_INPUT_SHA256=$sha" + echo "P2_HOSTED_EXPECTED_SIZE=$EXPECTED_CLIENT_SIZE" + echo "P2_HOSTED_EXPECTED_SHA256=$EXPECTED_CLIENT_SHA256" + } | tee "$out/input-status.txt" + + if [[ "$size" != "$EXPECTED_CLIENT_SIZE" || "$sha" != "$EXPECTED_CLIENT_SHA256" ]]; then + echo 'P2_HOSTED_INPUT_STATUS=INPUT_BLOCKED' | tee -a "$out/input-status.txt" + echo 'P2_HOSTED_INPUT_REASON=exact_client_fence_mismatch' | tee -a "$out/input-status.txt" + exit 3 + fi + + echo 'P2_HOSTED_INPUT_STATUS=EXACT_FENCE_VERIFIED' | tee -a "$out/input-status.txt" + echo "TRACK_A_P2_CLIENT=$client" >> "$GITHUB_ENV" + echo "TRACK_A_P2_DOWNSTREAM_EXACT_HEAD=$GITHUB_SHA" + echo 'TRACK_A_P2_DOWNSTREAM_EXACT_CLIENT_VERIFIED=true' + + - name: Prove persistent QBuffer downstream processor chain + shell: bash + run: | + set -Eeuo pipefail + out=artifacts/p2-buffer-downstream-consumer + python3 .github/scripts/tibia-official-client-re-p2-buffer-downstream-consumer.py \ + --client "$TRACK_A_P2_CLIENT" \ + --objdump "$TRACK_A_P2_OBJDUMP" \ + --output-json "$out/result.json" \ + --output-text "$out/result.txt" \ + --evidence "$out/evidence.txt" \ + | tee "$out/validation.log" + + grep -Fxq 'P2_DOWNSTREAM_COMPLETE=true' "$out/validation.log" + grep -Fxq 'P2_DOWNSTREAM_RESULT=POST_SERIALIZATION_PROCESSOR_CHAIN_PROVEN' "$out/validation.log" + grep -Fxq 'P2_DOWNSTREAM_PERSISTENT_QBUFFER_DIRECT_READALL=PROVEN' "$out/validation.log" + grep -Fxq 'P2_DOWNSTREAM_FIRST_CONSUMER=PROVEN' "$out/validation.log" + grep -Fxq 'P2_DOWNSTREAM_FIRST_TRANSFORM=PROVEN' "$out/validation.log" + grep -Fxq 'P2_DOWNSTREAM_SAME_MESSAGE_TO_DUALCONNECTION=PROVEN' "$out/validation.log" + grep -Fxq 'P2_DOWNSTREAM_PROTOCOL_STAGE_ORDER=PROVEN_PARTIAL' "$out/validation.log" + grep -Fxq 'P2_DOWNSTREAM_PROTOCOL_FRAMING=UNKNOWN' "$out/validation.log" + grep -Fxq 'P2_DOWNSTREAM_FINAL_BINARY_EGRESS=UNKNOWN' "$out/validation.log" + + python3 - "$out/result.json" <<'PY' + import json,sys + d=json.load(open(sys.argv[1],encoding='utf-8')) + assert d['schema_version']==3 + assert d['exact_client']['sha256']=='e6c244bd39fe2e0632f6f000efd3147164696efa8e901718668e0442325ff7fe' + assert d['exact_client']['size']==51965216 + assert d['semantic_result']=='POST_SERIALIZATION_PROCESSOR_CHAIN_PROVEN' + c=d['classification'] + assert c['persistent_qbuffer_direct_readall']=='PROVEN' + assert c['first_downstream_consumer'].startswith('PROVEN:') + assert c['first_downstream_transform'].startswith('PROVEN:') + assert c['same_message_handoff_to_dualconnection']=='PROVEN' + assert c['protocol_stage_order']=='PROVEN_PARTIAL' + for k in ('framing','sequence','compression','encryption','final_binary_egress','causal_local_harness'): + assert c[k]=='UNKNOWN' + n=d['negative_controls'] + assert not any(n.values()) + PY + echo 'TRACK_A_P2_DOWNSTREAM_HARDENED_VALIDATED=true' + + - name: Remove proprietary input before evidence upload + if: always() + shell: bash + run: | + set -Eeuo pipefail + rm -f "$RUNNER_TEMP/tibia-static-client" "$RUNNER_TEMP/tibia.x64.tar.gz" + test ! -e "$RUNNER_TEMP/tibia-static-client" + test ! -e "$RUNNER_TEMP/tibia.x64.tar.gz" + + - name: Upload sanitized static evidence only + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: track-a-p2-buffer-downstream-consumer-${{ github.run_id }} + path: artifacts/p2-buffer-downstream-consumer/** + if-no-files-found: error + retention-days: 7 diff --git a/docs/agents/tasks/active/OTC-20260815-track-a-p2-buffer-downstream-consumer.md b/docs/agents/tasks/active/OTC-20260815-track-a-p2-buffer-downstream-consumer.md new file mode 100644 index 0000000000..db1b0954e1 --- /dev/null +++ b/docs/agents/tasks/active/OTC-20260815-track-a-p2-buffer-downstream-consumer.md @@ -0,0 +1,215 @@ +--- +task_id: OTC-20260815-track-a-p2-buffer-downstream-consumer +status: ready +agent: unassigned +session_id: chatgpt-p2-sanitized-evidence-20260816-1635 +session_role: researcher +session_rotation_count: 3 +project_lane: otclient +lane: P2-NETWORK +track_id: official-client-re +task_kind: validation +phase: coordinator-promotion-ready +branch: research/OTC-20260815-track-a-p2-buffer-downstream-consumer +base_branch: main +base_main: c66e8b563f748e0595e3b7144c3fac3dc744c60c +worktree: github-only://blakinio/otclient/refs/heads/research/OTC-20260815-track-a-p2-buffer-downstream-consumer +worktree_mode: isolated_branch_checkout_equivalent +risk: medium +related_pr: 310 +created: 2026-08-15T21:40:00+02:00 +updated: 2026-08-16T16:38:00+02:00 +lease_expires_at: 2026-08-16T16:38:00+02:00 +owned_paths: + - docs/agents/tasks/active/OTC-20260815-track-a-p2-buffer-downstream-consumer.md + - docs/agents/evidence/OTC-20260815-track-a-p2-buffer-downstream-consumer/** + - .github/workflows/tibia-official-client-re-p2-buffer-downstream-consumer.yml + - .github/scripts/tibia-official-client-re-p2-buffer-downstream-consumer.py +modules_touched: [] +reuses: + - coordinator-promoted PR #308 exact retained QBuffer/QDataStream boundary + - PR #310 run 31904696996 sanitized exact-fence evidence bundle artifact 9252025461 + - coordinator PR #374 terminal disposition permitting resume on a pre-sanitized exact-binary evidence bundle +depends_on: + - current main@c66e8b563f748e0595e3b7144c3fac3dc744c60c + - coordinator promotion of closed-unmerged PR #308 as pinned evidence only +blocks: [] +policy_version: 2 +prompting_standard_version: 2.1 +prompt_contract_version: 1.0.0 +routing_contract: docs/agents/programs/OTCLIENT_TIBIA_RE_HYBRID_EXECUTION_ROUTING.md +track_a_runtime_agent_admission_version: 1 +execution_mode: github-only +execution_reason: review of a pre-sanitized exact-binary evidence bundle; no client rematerialization, live runtime or Synology execution is required or permitted +execution_class: github_hosted +runtime_access: none +persistent_session_role: consumer_of_runtime_evidence +physical_e2e_required: false +runtime_owner_task: NOT_APPLICABLE +runtime_namespace: NOT_APPLICABLE +canonical_registration: NOT_APPLICABLE +canonical_lease_generation: NOT_APPLICABLE +registration_lease_generation: NOT_APPLICABLE +gate_a: NOT_APPLICABLE +generation_rebind: NOT_APPLICABLE +gate_b: NOT_APPLICABLE +bootstrap: NOT_APPLICABLE +target_uniqueness: NOT_APPLICABLE +mutation_authorized: false +owner_funded_ai_api_authorized: false +run_scope: single_task +continuation_policy: continue_until_real_stop +task_completion_policy: draft_pr_only +user_communication: milestone_and_terminal +implementation_authorized: true +feature_scope: + type: protocol + user_facing: false + backend_required: false + frontend_required: false + integration_required: false + e2e_required: false + completion_claim: internal_research_only +context_pressure: medium +context_growth: stable +context_score: 8 +estimate_confidence: high +decomposition_decision: single +decomposition_reason: one bounded P2 post-serialization chain question with one owned validator/workflow and no runtime dependency +validation_level: focused +invocation_started_at: 2026-08-16T16:35:00+02:00 +last_progress_at: 2026-08-16T16:38:00+02:00 +ci_checks_for_current_head: 0 +ci_check_generation: sanitized-evidence-checkpoint +terminal_ci_wait_started_at: null +terminal_ci_checks_for_current_generation: 0 +unchanged_state_checks: 0 +identical_failure_retries: 0 +repair_cycles_for_current_gate: 0 +context_reconstruction_attempts: 1 +stall_warnings: 0 +heavy_validation_runs: 0 +heavy_validation_result: NOT_RUN_NO_BINARY_REMATERIALIZATION +terminal_invocation_result: WAITING_COORDINATOR_PROMOTION +runtime_nonclaims: + display_98_current_canonical_status: UNKNOWN + rfb_6082_current_backend_mapping: UNKNOWN + current_exact_client_pid: NOT_REGISTERED + current_exact_client_session: NOT_REGISTERED +historical_run_disposition: + run_31904696996: ACCEPTED_AS_SANITIZED_EXACT_FENCE_EVIDENCE_BUNDLE_FOR_REVIEW_NOT_AS_CURRENT_EXECUTION + run_31904967728: exact_client_static_failure_log_only + run_31944051248: QUARANTINED_ROUTING_VIOLATION_successful_static_synology_run_not_current_proof + run_31944074222: HOSTED_INPUT_BLOCKED_download.tibia.com_DNS_unresolved + run_31944119641: HOSTED_INPUT_BLOCKED_static.tibia.com_HTTP_403 + run_31951153838: SHARED_HOSTED_STAGING_DISCOVERY_INPUT_BLOCKED_PR374 + synology_static_rerun: FORBIDDEN_BY_CURRENT_ROUTING +sanitized_evidence_bundle: + run: 31904696996 + artifact: 9252025461 + artifact_digest: sha256:2a866247558b079944d81c9ad33bd4c5361c8144a7f367b273ab3bc19a080991 + expires_at: 2026-08-22T19:44:07Z + contains_client_or_package_bytes: false + contains_exact_fence_validation: true + exact_client_size: 51965216 + exact_client_sha256: e6c244bd39fe2e0632f6f000efd3147164696efa8e901718668e0442325ff7fe + evidence_files: + - validation.log + - result.json + - result.txt + - evidence.txt +current_compliant_result: + exact_binary_rematerialized_this_invocation: false + semantic_evidence_reviewed: true + persistent_qbuffer_direct_readall: PROVEN + first_downstream_consumer: PROVEN:TProtocolClientMessageProcessor+0x10@0xc2df80 + first_downstream_transform: PROVEN:TGameserverNetworkPacketRawDataProcessor+0x10@0xb47130 + same_message_handoff_to_dualconnection: PROVEN + protocol_stage_order: PROVEN_PARTIAL + framing: UNKNOWN + sequence: UNKNOWN + compression: UNKNOWN + encryption: UNKNOWN + final_binary_egress: UNKNOWN +proof_chain: + - setup provenance binds the persistent QBuffer to TProtocolClientMessageProcessor this+0x18 + - TProtocolClientMessageProcessor+0x10 reads that same member and calls QIODevice::readAll + - returned bytes are assigned to message QByteArray at message+0x8 + - the invoker passes the same message object to TGameserverNetworkPacketRawDataProcessor+0x10 + - RawDataProcessor performs QByteArray insert/append and assigns the transformed QByteArray back in place + - the same post-transform message object is then passed to TGameserverDualConnection virtual +0x80 and +0x78 +negative_controls: + generic_qiodevice_census_used_as_proof: false + generic_qbuffer_census_used_as_proof: false + vtable_adjacency_used_as_temporal_proof: false + historical_final_socket_run_used_as_proof: false + direct_dualconnection_writer_ownership_assumed: false + dual_plus80_or_plus78_labeled_final_egress: false + raw_byte_transform_labeled_framing_without_semantics: false +blocker: + type: COORDINATOR_PROMOTION_REVIEW + direct_exact_client_staging: INPUT_BLOCKED + shared_unblocker_pr: 374 + shared_unblocker_disposition: INPUT_BLOCKED_CLOSE_UNMERGED + pre_sanitized_bundle_resume_condition: SATISFIED_FOR_RESEARCH_REVIEW + coordinator_acceptance_of_bundle_for_promotion: PENDING + synology_fallback_allowed: false +e2e: + result: NOT_APPLICABLE + reason: static reverse-engineering evidence review only; no live/client runtime behavior changed or authorized +audit: + result: PASS_WITH_BOUNDED_CLAIMS + material_findings_open: 0 + notes: + - exact-fence evidence is carried by the preserved sanitized bundle and was not regenerated in this invocation + - no proprietary client/package bytes were downloaded, executed or uploaded in this invocation + - framing, sequence, compression, encryption and final binary egress remain UNKNOWN + - run 31944051248 remains quarantined and is not used as proof +active_operation: stopped at coordinator promotion boundary after reviewing the pre-sanitized exact-binary evidence bundle +last_completed_step: reviewed artifact 9252025461, verified exact-fence markers and concrete same-object data flow from persistent QBuffer readAll through RawDataProcessor into DualConnection handoff, and updated Draft PR #310 with the narrow supported classification +next_action: coordinator must independently review artifact 9252025461 and the exact PR #310 diff; if accepted, refresh/replay the three owned paths on current main without rerunning blocked exact-client staging, obtain exact-head governance/CI, then promote or close according to coordinator authority +--- + +# Objective + +Start from the coordinator-promoted P2 boundary and recover the first exact downstream consumer or transform of the retained byte-container state toward framing/final binary egress while separating proven data flow from unknown transport semantics. + +# Exact client fence + +```yaml +version_mapping: 15.32.df7b29 +size: 51965216 +sha256: e6c244bd39fe2e0632f6f000efd3147164696efa8e901718668e0442325ff7fe +platform: official_native_linux_only +execution_class: github_hosted +runtime_access: none +``` + +# Evidence-reviewed result + +The preserved sanitized exact-fence bundle from run `31904696996` / artifact `9252025461` contains exact size/SHA validation and sanitized disassembly. It proves the setup provenance from the persistent QBuffer into `TProtocolClientMessageProcessor this+0x18`; at `0xc2dfa5` that same member is loaded and at `0xc2dfd5` it is consumed by `QIODevice::readAll`. The bytes are assigned into the output message `QByteArray` at `message+0x8`. + +The exact invoker at `0x7dd630` then passes the same stack message object to `TGameserverNetworkPacketRawDataProcessor+0x10@0xb47130`, whose captured disassembly performs `QByteArray::insert`, `QByteArray::append` and an in-place `QByteArray::operator=` back to the same message field. The same message object is subsequently handed to `TGameserverDualConnection` virtual `+0x80` and `+0x78`. + +This supports only the following classifications: + +```yaml +persistent_qbuffer_direct_readall: PROVEN +first_downstream_consumer: PROVEN:TProtocolClientMessageProcessor+0x10@0xc2df80 +first_downstream_transform: PROVEN:TGameserverNetworkPacketRawDataProcessor+0x10@0xb47130 +same_message_handoff_to_dualconnection: PROVEN +protocol_stage_order: PROVEN_PARTIAL +framing: UNKNOWN +sequence: UNKNOWN +compression: UNKNOWN +encryption: UNKNOWN +final_binary_egress: UNKNOWN +``` + +No claim is made that the RawDataProcessor transform is framing, compression or encryption, and no DualConnection virtual is labelled final egress without exact transport semantics. + +# Current staging boundary + +Shared hosted staging discovery PR #374 was closed `INPUT_BLOCKED`. Do not add another guessed/direct/WARP HTTP retry and do not fall back to Synology. The coordinator explicitly permitted resume if a legally/technically compliant hosted exact input or a pre-sanitized exact-binary evidence bundle becomes available; artifact `9252025461` satisfies the latter condition for research review. Coordinator promotion authority remains separate and pending. + +Research stays Draft-only; coordinator owns promotion/merge/terminal closeout. \ No newline at end of file