Decoding Microsoft Bond: reading an app’s own telemetry through mitmproxy
Ground truth from an application with no test hooks. mitmproxy local mode against a single process, a hand-written Bond Compact Binary decoder, and the one-line AKS bug that killed a day.
Grading a computer-use agent means answering “did the application actually do the thing?” We had two bad options: ask the agent (it lies, sincerely) or look at pixels (fragile, and it is the same inference the agent already got wrong).
Then someone pointed out the obvious third option. Microsoft Teams reports its
own state — continuously, in detail — to Microsoft. Every click, every navigation,
every chat switch, timestamped and structured, sent to
teams.events.data.microsoft.com.
That stream is a perfect oracle. It is generated by the application itself, it describes what the application believes happened, and it has no idea we are reading it.
So we read it.
mitmproxy against one process
The first surprise was pleasant. mitmproxy has a local redirect mode that intercepts traffic from a specific process by name — no system proxy settings, no routing tricks, nothing else on the machine affected:
opts = options.Options(
listen_host="0.0.0.0",
listen_port=proxy_port,
mode=["local:msedgewebview2"],
ssl_insecure=True,
showhost=True,
)
proxy_master = DumpMaster(opts, loop=loop, with_termlog=False, with_dumper=False)
local:msedgewebview2 — the Teams desktop client is a WebView2 application, so
its network traffic comes from msedgewebview2.exe. mitmproxy hooks that process
and leaves everything else alone. Windows Update still talks to Microsoft
directly, the browser still works, and our capture contains only the app under
test.
That last property matters more than it sounds. A machine-wide proxy captures everything, and separating “the app did this” from “some background service did this” is a filtering problem you then have to solve badly. Process-scoped interception makes the noise not exist.
The proxy is wrapped in a FastAPI service so the scenario’s start and stop
hooks can control it with an HTTP call:
POST http://127.0.0.1:5052/start
POST http://127.0.0.1:5052/stop
Capture is scoped to a run, not to the machine’s lifetime.
Trusting the certificate
To read TLS you have to be trusted. The mitmproxy CA has to go into Windows’ machine Trusted Root store — not the user store, which looks identical in the UI and is ignored by anything running under another identity:
$cert = "$env:USERPROFILE/.mitmproxy/mitmproxy-ca-cert.cer"
certutil -addstore "Root" $cert
There is also a plain dependency on the Visual C++ redistributable, which is the sort of thing that is obvious in retrospect and takes an hour when the failure mode is a DLL load error inside a Python process inside a VM.
The provisioning chain does this automatically, and the post-install checklist still says verify the mitmproxy cert is in Trusted Root — because when it silently is not, every capture is empty and nothing else looks wrong.
Three payload formats, because of course
Teams does not send telemetry one way. The addon handles three.
def request(self, flow: HTTPFlow):
should_intercept = "teams.events.data.microsoft.com" in flow.request.host_header
if not should_intercept:
return
Format 1 — application/x-json-stream. Newline-delimited JSON, sometimes
gzipped, with the encoding declared in either a header or a query parameter:
query_encoding = flow.request.query.get("content-encoding")
encoding = flow.request.headers.get("content-encoding") or query_encoding
if encoding == "gzip":
body = decode_gzip(flow.request.content).decode("utf-8")
else:
flow.request.decode()
body = flow.request.content.decode("utf-8")
Note the fallback. Some requests declare gzip in a query parameter rather than the header, which is unusual enough that the naive header-only implementation silently produced garbage rather than an error.
Format 2 — application/bond-compact-binary. This is the desktop client’s
main path, and it is the hard one.
Format 3 — gzip announced only via query param, handled separately because by the time we understood it the code had already forked.
Microsoft Bond, from scratch
Bond is Microsoft’s cross-platform serialisation framework — same family as Protocol Buffers or Thrift. Compact Binary is its tight wire format: field IDs and type tags, no field names, no self-description worth speaking of. You cannot parse it without the schema.
There was no usable Python decoder for this. So the repository contains one:
addons/teams_telemetry/bond/
├── microsoft_bond.py
├── microsoft_bond_encoding.py
├── microsoft_bond_decoding.py
├── microsoft_bond_io.py
├── microsoft_bond_primitives.py
├── microsoft_bond_floatutils.py
├── microsoft_bond_collections.py
├── bond_const.py
└── microsoft_bond_exception.py
Starting from the type system:
class BondDataType(IntEnum):
BT_STOP = 0
BT_STOP_BASE = 1
BT_BOOL = 2
BT_UINT8 = 3
...
BT_STRING = 9
BT_STRUCT = 10
BT_LIST = 11
BT_SET = 12
BT_MAP = 13
...
BT_UNAVAILABLE = 127
class ProtocolType(IntEnum):
MARSHALED_PROTOCOL = 0
COMPACT_PROTOCOL = 16963
JSON_PROTOCOL = 21322
Then the schema. The payload is Microsoft’s Common Schema for 1DS telemetry,
and the .bond IDL is a public artifact:
namespace Microsoft.Azure.Collector.Models.CommonSchema.Bond
struct Ingest
{
[Name("IngestDateTime")]
1: required int64 time;
[Name("ClientIp")]
2: required string clientIp;
[Name("DataQuality")]
4: optional int64 quality;
...
}
Numbered fields, required/optional, [Name(...)] annotations mapping to the
JSON representation. From that we hand-wrote a deserialiser per extension
struct:
kusto_decoder/deserializers/
├── deserialize_record.py deserialize_data.py deserialize_value.py
├── deserialize_attribute.py deserialize_pii.py deserialize_customer_content.py
├── deserialize_app.py deserialize_device.py deserialize_os.py
├── deserialize_user.py deserialize_net.py deserialize_sdk.py
├── deserialize_loc.py deserialize_utc.py deserialize_m365a.py
└── deserialize_protocol.py
Sixteen deserialisers for the Common Schema extensions — app, device, OS, user, network, SDK, locale, UTC, PII markers, customer content markers.
The record separator
The batching is where it gets properly hostile. A single request contains many records concatenated, split by a byte sequence:
def decode_kusto_request(request) -> List[CsRecord]:
records = []
binary_records = request.split(b"\x03\51\46")
for binary_record_bytes in binary_records:
if len(binary_record_bytes) > 0:
reader = CompactBinaryProtocolReader()
reader.set_data(binary_record_bytes)
record = ClientToCollectorRequest.deserialize_record(reader)
if record["status"]:
records.append(record["record"])
return records
Look closely at that separator: b"\x03\51\46". It mixes hex and octal
escapes in one literal. \x03 is 3. \51 is octal 51 = 0x29. \46 is octal
46 = 0x26. So the actual bytes are 03 29 26.
I do not recommend writing byte literals this way; I am including it because it is exactly the sort of thing that survives from an initial reverse-engineering session into production code and then confuses everyone who reads it afterwards, including its author six weeks later.
Also note if record["status"] — the deserialiser returns a status flag rather
than raising. A corrupt or unrecognised record is skipped, not fatal. When you
are parsing an undocumented format from a vendor who can change it whenever they
like, partial success is the correct failure mode. Losing one record is fine;
losing the whole capture because one record had an unexpected field is not.
The AKS bug that cost a day
Everything worked on a laptop. Deployed to AKS, the proxy accepted connections and then immediately dropped them:
INFO - mitmproxy.proxy.server - log - client connect
WARNING - root - client_connected - Client connection from 20.20.20.21 killed by block_global option.
INFO - mitmproxy.proxy.server - log - client kill connection
INFO - mitmproxy.proxy.server - log - client disconnect
mitmproxy ships a block addon that refuses connections from non-private IP
addresses. It is a good default — an accidentally exposed proxy is an open relay,
and this stops the internet from using yours.
Azure CNI assigns pods addresses from the VNet range. Those addresses are routable within Azure and, to mitmproxy’s classifier, do not look like RFC 1918 private space. So it saw an apparently public client and did exactly what it was designed to do.
The fix is one line, plus a comment preserving the evidence:
# In case it is deployed to the AKS cluster, we got IP (ex. 20.20.20.21)
# which is considered to be a public IP.
# => ERROR: Client connection ... killed by block_global option.
# For that reason we need to remove the "Block" addon from proxy.
block_addon = proxy_master.addons.get("block")
proxy_master.addons.remove(block_addon)
The general lesson is broader than mitmproxy: libraries encode assumptions about networks that cloud environments violate. “Private IP means trusted” is a reasonable heuristic on a laptop and a wrong one inside a VNet. Every library with an IP-based security default is a candidate for this class of bug, and the symptom is always the same — works locally, silently refuses in the cluster.
What we actually got
Decoded records land as newline-delimited JSON, which the evaluator reads to check that the expected events occurred in the expected order:
with open(f"{TELEMETRY_PATH}\\{self.filename}", "a") as f:
f.write(json.dumps(record.to_json(), indent=None) + "\n")
Deliberately a file, not a database. It is trivially inspectable, trivially diffable between runs, and it survives the pod. There is commented-out Elasticsearch indexing right next to it, which was the intended next step and never became urgent — the file was enough, and “the file was enough” is an underrated architectural conclusion.
There is one more artifact worth mentioning: a JSON file of 1DS collector endpoints across environments and rings, gathered while working out which host to intercept. Sovereign clouds, EU data boundary tenants, government regions. It is a reminder that “the telemetry endpoint” is not one URL, and a filter hard-coded to one host will silently capture nothing for a tenant in a different region.
Would I do it again
Yes, with eyes open about the trade.
What it bought: a ground-truth oracle for an application with no test hooks, no automation API, and no accessible internal state. Grading went from “interpret a screenshot” to “check a sequence of events the app itself emitted.” That is a categorical improvement in trustworthiness, not an incremental one.
What it cost: a hand-written implementation of a binary serialisation format, coupled to an undocumented schema that the vendor can change without telling anyone. The 300-line batch script from the bootstrap post was more tedious; this was more fragile.
What makes it survivable: a canary. One scenario whose markers must match, run on a schedule. When the schema shifts, it goes red immediately and loudly, rather than quietly turning every benchmark into a false negative over the course of a fortnight. Build that at the same time as the decoder — not after the first silent failure.
Next: VolumeSnapshots, and the day a Windows boot went from forty minutes to ninety seconds.