← All advisories · 2026-07-21

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.

partialRed-team verdict
Public disclosureAdvisory: TS-2026-001Vendor: nanobot (HKUDS)Public disclosure
Found: 2026-07-21

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.

F-1

Missing filename sanitization in the attachment handler (path-traversal candidate)

med
Attack class: Path traversal / framework appsec (RT-3-adjacent)
What we tried

The 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.

What happened

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.

Why it matters

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.

F-2

The write happens before the sender allowlist

high
Attack class: Auth ordering / trust-boundary
What we tried

We traced the handler ordering in the real source: where does the download sit relative to the allow-list check?

What happened

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.

Why it matters

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.

F-3

Exactly one channel forgot the sanitizer the others use

med
Attack class: Inconsistent control across components
What we tried

We diffed every sibling channel in the same codebase for the same filename handling.

What happened

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.

Why it matters

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."

Source read: nanobot/channels/dingtalk/runtime.py · _download_dingtalk_file() vs. channels/email + channels/feishu (which call safe_filename)
What the code shows

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.

Confirmed by the live exploit
med

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.

Refuted — static false positives

"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.

Root cause & fix. Missing input sanitization at an untrusted-input boundary, ordered before authentication. Fix (one-line-shaped): apply the existing safe_filename helper before the path join, and move the sender allow-list check ahead of any attachment download so an unapproved sender can't cause a fetch/write at all.

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.

Takeaway. The honest lesson here is about us, not just the target: it's easy to feed a sink a malicious value yourself and call it a break. The discipline is to prove untrusted input actually reaches it. On this one, we found a real code gap the maintainers already guard against elsewhere — worth reporting — but we haven't earned the word 'exploit', so we're not using it. A verdict you can act on is one whose author draws that line even when it's less impressive.

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.

Building an agent with real reach? Have it red-teamed →