nanobot (HKUDS)
a widely-deployed open-source self-hosted agent that fronts many chat platforms (DingTalk, Feishu, Telegram, WhatsApp, email, …) behind one bot. Tested at v0.2.2, commit 9db0d9f (latest main). Its LLM front line is genuinely hardened — it refused every prompt-injection variant, decoding our obfuscated payloads to inspect them first — so we broke it through its own framework code. Disposable cloud sandbox, our own fork.
Candidate — not a confirmed exploit. We confirmed the code omits the filename sanitizer its sibling channels use, and that the write sink escapes the media dir given a traversal filename. But we supplied that filename ourselves; whether a real DingTalk message can deliver a fileName containing '../' is unverified — and may be constrained by the platform. Published openly as a hardening gap to fix; we do not claim a working exploit.
An honest candidate, held to our own bar. In nanobot's DingTalk channel (nanobot/channels/dingtalk/runtime.py), an inbound attachment's fileName is written to disk with no sanitization, during message parsing, before the sender allowlist — while the sibling channels (email, feishu) all run it through safe_filename first. We confirmed two things in a disposable sandbox: the sanitizer is missing, and the write sink escapes the media dir when handed a traversal filename. What we did NOT confirm is the one thing that decides exploitability — that a real, unapproved DingTalk sender can actually deliver a fileName containing '../'. We stubbed that transport and supplied the malicious filename ourselves, so the 'live-fire' really only re-confirmed the static read. Candidate ≠ vulnerability; we're not going to call this a break until untrusted input is proven to reach the sink.
Missing filename sanitization in the attachment handler (path-traversal candidate)
medThe channel's attachment handler builds the save path as download_dir / fileName, where fileName comes straight off the inbound message and is never sanitized. We drove the agent's own, unmodified download function with fileName = "../"×12 + a random marker path — stubbing only the vendor file-transport that a real inbound file message triggers anyway.
With a traversal filename that we passed in directly, the unmodified sink wrote outside the media dir (realpath in /tmp; marker deleted after). But that mostly re-confirms the static read — Python's Path join doing no sanitization is expected once you assume a malicious filename. The load-bearing assumption — that an attacker can get '../' into a real DingTalk fileName — we did not test; we stubbed the DingTalk transport. On the sender side a filename normally can't contain path separators, so real-world reachability is uncertain and platform-dependent.
IF fileName is attacker-controllable end-to-end, this is an unauthenticated arbitrary file write (overwrite config → MITM/key theft; plant a startup file → RCE) — a serious bug. That 'if' is exactly what we haven't earned the right to drop yet. By the project's own threat model it's worth fixing regardless: email and feishu sanitize; this channel doesn't.
The write happens before the sender allowlist
highWe traced the handler ordering in the real source: where does the download sit relative to the allow-list check?
The attachment is fetched and written during message parsing — the sender id isn't even resolved until after the download, and the allow-list is consulted only downstream. So the write is pre-auth: an unapproved stranger triggers it. The LLM never sees it; the allowlist never gates it.
This is why the strong model and the sender allowlist both give a false sense of safety here — the dangerous operation runs on a path that bypasses both.
Exactly one channel forgot the sanitizer the others use
medWe diffed every sibling channel in the same codebase for the same filename handling.
The sibling channels run the inbound filename through a shared safe_filename helper — and the project even ships a regression test asserting the ../../../etc/passwd case is neutralized on another channel. This one channel never imports it. Not a systemic design flaw: a single-channel oversight, which is exactly why the fix is one line.
The tell is what makes the finding credible and the fix cheap: the codebase already knows how to do this safely everywhere else.
Static ↔ dynamic cross-validation
Static reading found the candidate sink and the auth ordering; the live exploit decided it was real. The arbiter is whether the write actually escaped — not a model vote, and not "we read it in the code."
The handler passes the raw inbound fileName into a download-and-write function that does file_path = download_dir / filename; file_path.write_bytes(...) with no sanitization, and the write runs before the allow-list. Candidate: attacker-controlled traversal escapes the media dir, unauthenticated. Sibling channels sanitize; this one doesn't.
What's actually confirmed: (1) the DingTalk channel omits the safe_filename call its sibling channels use; (2) given a traversal filename, the unmodified sink writes outside the media dir. Both are code-level facts. What is NOT confirmed — and what would make this an exploit — is that an attacker can deliver such a fileName through a real DingTalk message. We supplied it ourselves. So the honest status is candidate, not confirmed break.
"The hardened LLM protects this." Refuted: the vulnerable path is in the channel's file handler; the model is never in the loop, so its (real, strong) injection resistance is irrelevant here.
"It's a systemic filename-handling flaw." Refuted by diffing the codebase: the sibling channels sanitize and there's a regression test elsewhere — it's one channel that forgot. Report the one; don't inflate it into a design indictment.
The exploit chain
The potential chain — IF a DingTalk fileName is attacker-controllable: one inbound file message from an unapproved stranger → a byte-controlled file written to an attacker-chosen path → overwrite config (MITM/key theft) or a startup file (RCE). We proved only the middle mechanic (the sink escapes the dir given a bad filename), not the first link (that an attacker can supply that filename). Until the first link is proven, this chain is hypothetical — which is exactly why we're labeling it a candidate, not a break.
Disclosure
Advisory TS-2026-001 — status: candidate, publicly disclosed. Tested only on our own fork of the public source (v0.2.2, commit 9db0d9f) in a disposable cloud sandbox, with a random harmless marker — no destructive action, no exfiltration, no persistence, marker deleted after. Public disclosure (no private vendor pre-notification): the DingTalk channel doesn't sanitize inbound filenames the way email and feishu do, and IF a DingTalk fileName can carry '../', it's an unauthenticated arbitrary file write. We have not verified that 'if', so we label it a candidate rather than claiming a working exploit. Either way, applying the existing safe_filename helper (and running the allowlist before any download) is a cheap, correct fix.