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:
- LobsterAI explicitly instructs the assistant to use absolute local file paths when responding over IM channels.
- The shared media parser accepts absolute local paths from reply text and turns them into media markers.
- 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
- Download the experiment PoC from: verification_test.py
- Download the control PoC from: control-relative-path.py
- From the LobsterAI repository root, run the experiment PoC:
python3 verification_test.py
- Confirm that the script compiles the real production NIM gateway sources and reports a forwarded path of
/tmp/lobster-cve-29611-nim-canary.txt.
- Run the control PoC:
python3 control-relative-path.py
- 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/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. |
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:
marker.pathinto 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.xlsxand states that absolute paths are required: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:The sink is the NIM outbound media path.
sendReplyWithMedia()iterates over the parsed markers and callssendMedia(to, marker.path)as long as the file exists:sendNimMediaMessage()then performs only an existence check and a size check before passing the same host-local path into the NIM SDK:I verified this with a runtime component PoC that compiles the real production
nimGateway.ts,dingtalkMediaParser.ts, andnimMedia.tssources, stubs the NIM SDK client, and invokes the public gateway entrypointNimGateway.sendNotificationWithMedia(text). The experiment case reachescreateFileMessage("/tmp/lobster-cve-29611-nim-canary.txt", ...), while the control case changes only the marker to[secret](relative.txt)and produces nocreateFileMessagecall.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
2026.6.15, or any earlier affected version.>=24 <25.npm install.LOBSTERAI_REPO_ROOT=/path/to/LobsterAIbefore invoking the scripts.Reproduction Steps
python3 verification_test.py/tmp/lobster-cve-29611-nim-canary.txt.python3 control-relative-path.pycreateFileMessagecall when the only change is replacing the absolute path marker with[secret](relative.txt).Log of Evidence
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
Severity
Weaknesses
Occurrences
LobsterAI/src/main/im/imMediaInstruction.ts
Lines 29 to 50 in 5351d1e
/tmp/result.xlsxand states that absolute paths are required.LobsterAI/src/main/im/imChatHandler.ts
Lines 55 to 60 in 5351d1e
LobsterAI/src/main/im/imCoworkHandler.ts
Lines 400 to 421 in 5351d1e
LobsterAI/src/main/im/dingtalkMediaParser.ts
Lines 97 to 113 in 5351d1e
LobsterAI/src/main/im/dingtalkMediaParser.ts
Lines 147 to 158 in 5351d1e
LobsterAI/src/main/im/nimGateway.ts
Lines 1046 to 1078 in 5351d1e
sendReplyWithMedia()parses the assistant reply, checks onlyfs.existsSync(marker.path), and forwards each parsed local path intosendMedia().LobsterAI/src/main/im/nimMedia.ts
Lines 278 to 315 in 5351d1e
sendNimMediaMessage()performs only existence and size checks before callingcreateFileMessage(filePath, baseName, '')with the attacker-influenced host-local path.