攻擊者可以使用Node.js應用侵入你的系統。本文介紹如何阻止這種行爲的發生。
當Node.js首次發佈時,它引起了一場革命。它允許開發人員在服務器端運行JavaScript,這是瀏覽器的主要編程語言。隨着時間的推移,Node.js變得越來越流行,併成爲構建Web應用程序和API的首選工具。
Node.js由一個小而穩定的運行時核心和一組內置模塊組成,這些內置模塊提供了一些基本功能,如文件系統訪問、TCP/IP網絡、HTTP協議、加密算法、解析命令行參數等。這些內置模塊功能強大,經過了充分的測試並且性能良好。
不過,它並不能涵蓋Web應用程序開發人員的都有需求。有時,我們需要使用Node.js之外的程序來實現我們正在開發的功能。
使用CLI擴展Node.js模塊
在Node.js的內置模塊中,有一個叫做child_process的模塊,它允許JavaScript代碼運行其它程序並通過底層操作系統提供的標準輸入/輸出(I/O)機制進行通信。這樣的程序通常可以由用戶通過命令行界面(簡稱CLI)來啓動。這也是我們爲什麼把這樣的程序稱之爲CLI的原因。
像Linux這樣的現代操作系統包含許多實用工具,這些工具並沒有包含在Node.js的內置模塊中。我們可以使用child_process模塊啓動一個外部程序來實現特定的功能,通過這種方法來擴展Node.js標準庫既簡單又有效。
檢索git歷史記錄
讓我們看一下如何實現一個簡單的Node.js程序,從git中獲取文件的修改記錄。我們使用Express框架來處理HTTP請求和響應。由於Node.js的內置模塊中沒有可以直接運行git命令的方法,所以我們必須自己運行git命令來完成數據的檢索。要做到這一點,我們需要使用child_process模塊中的exec函數:
const exec = require('child_process').exec; app.get('/history', (req, res) => { // Read file name from the URL query string. const file = req.query.file; // Prepare command to run const command = `git log --oneline ${file}`; // Execute the external program and send the response back exec(command, (err, output) => { // Respond with HTTP 500 if there was an error if (err) { res.status(500).send(err); return; } // If the program ran successfully, send the output in // the HTTP response res.send(output); }); });
現在我們給程序發送一個HTTP請求來獲取文件的歷史修改記錄。我們使用curl命令來完成這一操作,curl是一個很流行的CLI程序:
$ curl http://localhost:3000/history?file=app.js
程序輸出以下結果:
f38482b Call git log command
5444afb Initial commit
看起來我們的程序運行良好。我們只需要幾行代碼就實現了一個相當複雜的操作。
調用命令
我們的程序簡單而靈活。我們可以將服務器上任何文件的路徑傳遞給我們的程序,只要該文件在git的版本控制下,我們就可以獲得它的修改記錄。這種輸入參數可靠嗎?
參數中的不可信數據
我們的應用程序,特別是那些面向互聯網的應用程序,會暴露給各種不同的用戶。有些用戶可能會爲了自己的目的而濫用我們應用程序的功能。而有些用戶則是爲了樂趣和學習,還有些用戶是爲了出名,甚至爲了錢。
強烈建議在編程實踐中始終將用戶的輸入都視爲不可信的,除非我們有其它的證明。在Web應用程序中,攻擊者可能會通過HTTP請求來嘗試所有的功能,從而迫使我們的應用程序做一些違背意願的事情。可能的攻擊向量包括HTTP請求體、請求頭(包括cookies),以及字符串查詢參數。
通過濫用這些修改後的輸入參數,攻擊者可以從服務器泄露敏感信息、導致服務拒絕訪問、甚至完全接管整個服務器。對我們編寫的這個應用程序而言,擁有惡意意圖的用戶可以濫用file參數,這一點是顯而易見的。
調用惡意的命令
我們的程序通過使用JavaScript模板字面量來構造一個完整的命令:javascript const command = `git log --oneline ${file}`;
如果提供的file參數的值是app.js,則執行的命令爲:bash $ git log --oneline app.js
攻擊者可能會提供另外一個值,它可以改變整個命令行的結構。讓我們看看如果將參數file的值改成app.js; ls會發生什麼:
$ git log --oneline app.js;ls
我們的程序執行git log命令後,又執行了ls命令。在HTTP響應中發送給用戶的結果是兩個命令的輸出組合:
f38482b Call git log command 5444afb Initial commit app.js bin node_modules package-lock.json package.json public routes views
這種類型的安全漏洞被稱之爲命令行注入。使用這種攻擊技術會造成更大的影響嗎?讓我們嘗試通過下面的方式來獲取應用程序的源代碼:
$ curl http://localhost:3000/history\?file\=app.js\;cat%20app.js
輸出結果不僅包含了app.js文件的修改記錄,還包含了通過cat app.js命令獲取到的整個文件的內容。
我們可以使用相同的方法來泄露應用程序可以訪問的服務器上的任何文件的內容,包括配置文件。攻擊者甚至還可以通過env命令讀取系統環境變量的值。
對於大多數惡意用戶而言,這已經非常有用了,但他們還可以做更多的事情。
現實的攻擊
技術嫺熟的攻擊者不僅試圖控制應用程序,還試圖控制整個服務器。這可以讓他們獲得對受感染機器的永久訪問權。
極簡的shell
許多服務器操作系統都有一個名爲nc(或者netcat)的工具。如果攻擊者可以通過命令行注入漏洞的方式運行這個程序,那麼他就可以在受感染的服務器上執行任意命令了。最簡單的方式就是對易受攻擊的應用程序強制運行以下命令:
$ nc -l 6667 | /bin/bash
該命令偵聽端口6667(由攻擊者選擇)上的傳入連接,並將所有傳入的數據直接傳遞給bash shell執行。假設端口是可用的,讓我們看看這種方式是否可以通過我們的應用程序來實現:
$ curl http://localhost:3000/history?file=app.js;nc%20-l%206667%20|/bin/bash
現在攻擊者可以向受感染的服務器發送任意命令了:
$ echo 'killall node' | nc localhost 6667
在這個例子中,攻擊者使用nc程序向已經設置好的bash shell發送killall node命令。其結果就是導致拒絕服務攻擊,終止服務器上的所有Node.js進程。
權限提升和內網漫遊
對攻擊者來說,能夠在服務器上運行任意命令非常有吸引力。在典型的攻擊場景中,以這種方式破壞服務器只是攻擊者採取的第一步。接下來是在被攻擊的機器上安裝惡意軟件,從而允許攻擊者可以長時間地與服務器通信。理想情況下,在被攻擊的Node.js應用程序重啓之後仍然可以有效地控制服務器。
理想情況下,我們的Node.js應用程序以最小的權限集運行。命令行注入攻擊允許攻擊者對基礎設施進行偵察並竊取管理權限,或者查找其它漏洞並進行錯誤設置,使攻擊者獲得權限提升,從而進一步通過網絡進行傳播。
通過訪問一臺被攻擊的服務器,攻擊者可以轉移到網絡上的其它主機,這一過程被稱之爲內網漫遊。這使得攻擊者可以攻擊網絡上更多的主機,從而獲得更多的權限,並進一步從我們的基礎設施中尋找其它有趣的目標和數據。
如果不被發現,這種攻擊可以持續數週甚至數月,從而導致嚴重的數據泄露。那麼我們如何加強我們的Node.js應用程序,以防止惡意用戶利用命令行注入漏洞進行攻擊呢?
防止命令行注入
有幾種技術可以防止或者至少可以極大地減少這種攻擊的可能性。
不執行任意命令
我們使用child_process模塊中的exec函數,將傳遞給它的第一個參數的值作爲命令直接傳遞給shell執行。這種操作非常靈活,但同時也帶來了安全隱患,正如我們剛纔所看到的。
更好的方式是使用child_process模塊中的execFile函數,它可以將參數作爲數組傳遞給特定的命令:
**const execFile = require('child_process').execFile;** app.get('/history', (req, res) => { // Read file name from the URL query string. const file = req.query.file; // Prepare command to run const command = `/usr/local/bin/git`; const args = ["log", "--oneline", file]; // Execute the external program and send the response back execFile(command, args, (err, output) => { // Respond with HTTP 500 if there was an error if (err) { res.status(500).send(err); return; } // If the program ran successfully, send the output in // the HTTP response res.send(output); }); });
通過這種方式,我們的應用程序可以抵禦命令行注入攻擊:
$ curl http://localhost:3000/history\?file\=app.js\;ls
在服務器端,這將引發預期的錯誤:
{"killed":false,"code":128,"signal":null,"cmd":"/usr/local/bin/git log --oneline app.js;ls"}
那如果我們需要exec函數的靈活性怎麼辦呢?有更好的解決辦法嗎?
輸入驗證還是輸出清理?
理想情況下,最好兩者都有!
命令行注入攻擊的根本原因在於,當我們在代碼中構造要執行的命令時,一些特殊字符(元字符)可能會改變命令的結構。最常用的元字符有:
& ; ` ' \ " | * ? ~ < > ^ ( ) [ ] { } $ \n \r
確保這些字符不被攻擊者濫用的最好方法是對不可信的輸入數據執行嚴格的驗證。輸入驗證可以驗證數據的來源、大小或詞法結構等內容。確保儘可能縮小可接受值的範圍。
如果傳入的數據含有元字符,我們需要在傳遞給shell之前將這些字符進行適當的轉義,以防止命令行注入攻擊。自己實現這個功能比較困難,最好是使用可信且經過良好測試的庫。shell-quote是個不錯的選擇,我們可以使用npm包管理器進行安裝,然後使用它來格式化整個命令:
const quote = require('shell-quote').quote; // ... // Prepare command to run const command = quote(["git", "log", "--oneline", file]); // Execute the external program and send the response back exec(command, (err, output) => {
看起來這個方案不錯,但是shell元字符編碼是一個困難的問題,甚至在已建立的庫中也發現了潛在的bug。
總結
如果你的Node.js應用程序是通過調用外部程序來擴展內置模塊的功能,那麼可能就有命令行注入漏洞的風險。這些漏洞允許攻擊者濫用我們的應用程序,並在我們的基礎設施中進行內網漫遊。
聽起來很可怕是吧?沒錯,這是一個非常嚴重的風險。
好消息是我們可以採取幾種防禦性的編碼方式來幫助我們構建不受命令行注入攻擊的庫和應用程序。使用execFile函數可以防止任意shell命令被執行,這是推薦的防禦方法。另外,對輸入值進行shell元字符驗證也是一種有效的保護措施。
關於Auth0
Okta的Auth0採用了一種現代化的用戶身份識別方式,使組織能夠爲任何用戶提供對任何應用程序的安全訪問。Auth0是一個高度可定製的平臺,開發團隊可以根據需要對其進行簡單而靈活的設置。Auth0每月保護數十億次的用戶登錄,它給用戶提供了便利、隱私和安全性,使客戶能夠專注於自己的創新領域。如想了解更多信息,可以訪問https://auth0.com