Skip to content

[Security] LobsterAI NIM outbound media flow allows arbitrary host-local file exfiltration via assistant-generated absolute paths #2287

Description

@YLChen-007

Advisory Details

Title: LobsterAI NIM outbound media flow allows arbitrary host-local file exfiltration via assistant-generated absolute paths

Description:
LobsterAI's NIM integration treats assistant-generated absolute local file paths as outbound media attachments. An approved remote IM sender can influence the assistant to emit such a marker and cause the LobsterAI host process to hand the referenced local file directly to the NIM SDK for delivery.

Summary

An approved NIM sender can trigger transmission of arbitrary local files readable by the LobsterAI desktop process. The issue exists because assistant reply text crosses a trust boundary into a host-side file-send primitive without any trusted-root allowlist or canonical containment check.

Details

The vulnerable flow is composed of three parts:

  1. LobsterAI explicitly instructs the assistant to use absolute local file paths when responding over IM channels.
  2. The shared media parser accepts absolute local paths from reply text and turns them into media markers.
  3. The NIM gateway forwards each parsed marker.path into the NIM SDK file-send primitive after only existence and size checks.

The instruction path is not accidental. buildIMMediaInstruction() tells the model to embed absolute paths such as /tmp/result.xlsx and states that absolute paths are required:

在回复文本中使用以下 Markdown 格式嵌入本地文件路径
- **文件**: `[文件名](/absolute/path/to/document.pdf)`
1. **必须使用绝对路径**

That instruction is appended to IM conversations in both chat and cowork modes before the LLM response is generated.

The parser then accepts those paths as trusted media references. For Markdown links, parseMediaMarkers() resolves the path, infers the media type from the extension, and pushes the path into the marker list:

const path = cleanPath(rawPath);
const mediaType = getMediaTypeByExtension(path);
if (mediaType && !processedPaths.has(path)) {
  markers.push({ type: mediaType, path, name, originalMarker: fullMatch });
}

The sink is the NIM outbound media path. sendReplyWithMedia() iterates over the parsed markers and calls sendMedia(to, marker.path) as long as the file exists:

const markers = parseMediaMarkers(text);
...
if (!fs.existsSync(marker.path)) {
  continue;
}
await this.sendMedia(to, marker.path);

sendNimMediaMessage() then performs only an existence check and a size check before passing the same host-local path into the NIM SDK:

if (!fs.existsSync(filePath)) {
  throw new Error(`File not found: ${filePath}`);
}
const fileSize = fs.statSync(filePath).size;
...
message = messageCreator.createFileMessage(filePath, baseName, '');

I verified this with a runtime component PoC that compiles the real production nimGateway.ts, dingtalkMediaParser.ts, and nimMedia.ts sources, stubs the NIM SDK client, and invokes the public gateway entrypoint NimGateway.sendNotificationWithMedia(text). The experiment case reaches createFileMessage("/tmp/lobster-cve-29611-nim-canary.txt", ...), while the control case changes only the marker to [secret](relative.txt) and produces no createFileMessage call.

I also attempted a stronger GUI-based end-to-end validation path by building the Electron app and launching npm run electron:dev. That setup succeeded at the bundle level, but a full live reproduction was not feasible in the container because this flow requires a GUI-capable runtime, a real logged-in NIM account/session, and a configured LLM provider. The attached unit harness is still strong evidence because it executes the real vulnerable production code path and records the exact outbound SDK call.

PoC

Prerequisites

  • Checkout a vulnerable LobsterAI revision, such as release 2026.6.15, or any earlier affected version.
  • Use Node.js >=24 <25.
  • Install project dependencies with npm install.
  • Have Python 3 available.
  • Run the PoC from the LobsterAI repository root, or set LOBSTERAI_REPO_ROOT=/path/to/LobsterAI before invoking the scripts.

Reproduction Steps

  1. Download the experiment PoC from: verification_test.py
  2. Download the control PoC from: control-relative-path.py
  3. From the LobsterAI repository root, run the experiment PoC: python3 verification_test.py
  4. Confirm that the script compiles the real production NIM gateway sources and reports a forwarded path of /tmp/lobster-cve-29611-nim-canary.txt.
  5. Run the control PoC: python3 control-relative-path.py
  6. Confirm that the control case reports no createFileMessage call when the only change is replacing the absolute path marker with [secret](relative.txt).

Log of Evidence

[control-case] payload='please send this file\n[secret](relative.txt)'
[DingTalk MediaParser] 解析完成, 共发现 0 个媒体标记: []
[control-evidence] relative path did not trigger createFileMessage

[case] payload='please send this file\n[secret](/tmp/lobster-cve-29611-nim-canary.txt)'
[DingTalk MediaParser] 发现 Markdown 链接: {"rawPath":"/tmp/lobster-cve-29611-nim-canary.txt","cleanedPath":"/tmp/lobster-cve-29611-nim-canary.txt","mediaType":"file","name":"secret","fullMatch":"[secret](/tmp/lobster-cve-29611-nim-canary.txt)"}
[{"kind":"createFileMessage","args":["/tmp/lobster-cve-29611-nim-canary.txt","lobster-cve-29611-nim-canary.txt",""]}]
[evidence] forwarded_path=/tmp/lobster-cve-29611-nim-canary.txt

Impact

This is an arbitrary local file exfiltration issue in the NIM outbound media feature. Any approved remote NIM sender who can influence assistant output can attempt to make LobsterAI transmit host-local files readable by the desktop process, including local documents, generated artifacts, configuration files, and other sensitive material. The impact is confidentiality loss on the LobsterAI host.

Affected products

  • Ecosystem: GitHub
  • Package name: netease-youdao/LobsterAI
  • Affected versions: <= 2026.6.15
  • Patched versions:

Severity

  • Severity: Medium
  • Vector string: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

Weaknesses

  • CWE: CWE-73: External Control of File Name or Path

Occurrences

Permalink Description
在回复文本中使用以下 Markdown 格式嵌入本地文件路径,系统会自动检测并将其作为对应类型的媒体消息发送给用户:
- **图片**: \`![描述文字](/absolute/path/to/image.png)\`
- **音频**: \`[音频文件](/absolute/path/to/audio.mp3)\`
- **视频**: \`[视频文件](/absolute/path/to/video.mp4)\`
- **文件**: \`[文件名](/absolute/path/to/document.pdf)\`
也可以直接在文本中写出文件的绝对路径(裸路径),系统同样能识别:
- \`/Users/xxx/output/chart.png\`
- \`/tmp/result.xlsx\`
### 支持的文件类型
- 图片: jpg, jpeg, png, gif, webp, bmp
- 音频: mp3, wav, aac, m4a, ogg, amr
- 视频: mp4, mov, avi, mkv, webm
- 文件: pdf, doc/docx, xls/xlsx, ppt/pptx, zip, txt, json, csv, md 等
### 使用规则
1. **必须使用绝对路径**,如 \`/Users/...\`、\`/tmp/...\` 等。
2. 文件必须是你通过工具创建或已确认存在于本地磁盘的文件。
The IM system prompt explicitly tells the assistant to use absolute local paths such as /tmp/result.xlsx and states that absolute paths are required.
// Append IM media sending instruction
const mediaInstruction = buildIMMediaInstruction(this.options.imSettings);
if (mediaInstruction) {
systemPrompt = systemPrompt
? `${systemPrompt}\n\n${mediaInstruction}`
: mediaInstruction;
The chat-mode IM handler appends the media instruction to the system prompt before generating the assistant response.
// Build media instruction for IM media sending capability
const mediaInstruction = buildIMMediaInstruction(imSettings);
const sections: string[] = [];
if (systemPrompt) {
sections.push(systemPrompt);
}
if (imSettings.skillsEnabled && this.getSkillsPrompt) {
const skillsPrompt = await this.getSkillsPrompt();
if (skillsPrompt) {
sections.push(skillsPrompt);
}
}
if (scheduledTaskPrompt) {
sections.push(scheduledTaskPrompt);
}
// Append media instruction at the end so it's always present
if (mediaInstruction) {
sections.push(mediaInstruction);
The cowork-mode IM handler also appends the same media instruction, making the behavior reachable in cowork sessions.
// 2. 解析普通 Markdown 链接 [text](path) 中的媒体文件
for (const match of text.matchAll(MARKDOWN_LINK_RE)) {
const [fullMatch, linkText, rawPath] = match;
const path = cleanPath(rawPath);
const mediaType = getMediaTypeByExtension(path);
// 使用链接文本作为文件名(如果有的话)
const name = linkText?.trim() || undefined;
console.log(`[DingTalk MediaParser] 发现 Markdown 链接:`, JSON.stringify({ rawPath, cleanedPath: path, mediaType, name, fullMatch }));
if (mediaType && !processedPaths.has(path)) {
processedPaths.add(path);
markers.push({
type: mediaType,
path,
name,
originalMarker: fullMatch,
});
}
The shared media parser accepts Markdown links containing absolute local paths and converts them into media markers without any trusted-root validation.
// 5. 解析裸文件路径 (txt, pdf, doc, etc.)
for (const match of text.matchAll(BARE_FILE_PATH_RE)) {
const [fullMatch, rawPath] = match;
const path = cleanPath(rawPath.trim());
console.log(`[DingTalk MediaParser] 发现裸文件路径:`, JSON.stringify({ rawPath, cleanedPath: path, fullMatch: fullMatch.trim() }));
if (!processedPaths.has(path)) {
processedPaths.add(path);
markers.push({
type: 'file',
path,
originalMarker: fullMatch.trim(),
});
The same parser also accepts bare absolute file paths and marks them as outbound files.
private async sendReplyWithMedia(to: string, text: string): Promise<void> {
// 1. 解析媒体标记
const markers = parseMediaMarkers(text);
if (markers.length === 0) {
// 没有媒体标记,纯文本发送
await this.sendLongText(to, text);
return;
}
this.log(`[NIM Gateway] Found ${markers.length} media marker(s) in reply`);
// 2. 先发送去除标记后的文本(如果有)
const strippedText = stripMediaMarkers(text, markers);
if (strippedText.trim()) {
await this.sendLongText(to, strippedText);
// 文本和第一个媒体之间的间隔
await new Promise((resolve) => setTimeout(resolve, 100));
}
// 3. 逐个发送媒体文件
for (let i = 0; i < markers.length; i++) {
const marker = markers[i];
try {
// 检查文件是否存在
if (!fs.existsSync(marker.path)) {
this.log(`[NIM Gateway] Media file not found: ${marker.path}`);
continue;
}
await this.sendMedia(to, marker.path);
this.log(`[NIM Gateway] Sent media: ${marker.type} ${marker.path}`);
sendReplyWithMedia() parses the assistant reply, checks only fs.existsSync(marker.path), and forwards each parsed local path into sendMedia().
if (!fs.existsSync(filePath)) {
throw new Error(`File not found: ${filePath}`);
}
/** 发送文件大小上限:100MB */
const MAX_SEND_FILE_SIZE = 100 * 1024 * 1024;
const fileSize = fs.statSync(filePath).size;
if (fileSize > MAX_SEND_FILE_SIZE) {
throw new Error(
`文件过大: ${(fileSize / 1024 / 1024).toFixed(1)}MB,超出 100MB 发送限制`,
);
}
const mediaType = inferMediaType(filePath);
const baseName = path.basename(filePath);
let message: any;
switch (mediaType) {
case 'image':
// createImageMessage(filePath, name, sceneName, width, height)
message = messageCreator.createImageMessage(filePath, baseName, '', 0, 0);
break;
case 'audio':
// createAudioMessage(filePath, name, sceneName, duration)
message = messageCreator.createAudioMessage?.(filePath, baseName, '', 0);
break;
case 'video':
// createVideoMessage(filePath, name, sceneName, duration, width, height)
// 默认 1920x1080,与 openclaw-nim/src/outbound.ts 一致
message = messageCreator.createVideoMessage?.(filePath, baseName, '', 0, 1920, 1080);
break;
case 'file':
default:
// createFileMessage(filePath, name, sceneName)
message = messageCreator.createFileMessage(filePath, baseName, '');
sendNimMediaMessage() performs only existence and size checks before calling createFileMessage(filePath, baseName, '') with the attacker-influenced host-local path.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions