在本文中,我们将为读者详细介绍如何利用GWTMap工具对GWT应用进行安全审查。
简介
GWTMap是一款新型的工具,可以用来检测基于Google Web Toolkit(GWT)应用程序的攻击面。这个工具的设计初衷,就是帮助安全人员提取隐藏在现代GWT应用程序经过混淆处理的客户端代码中的服务方法端点,并尝试生成示例GWT-RPC请求payload来与它们进行交互。
读者可以从网上下载该工具,具体地址为:https://github.com/FSecureLABS/GWTMap。
WTF其实就是GWT,它有何过人之处吗?
Google Web Toolkit(GWT)是一个开发框架,Java开发者可以利用该框架来创建单页应用程序(SPA),而无需编写任何JavaScript或(大部分)HTML代码。这样的话,Java开发者只需从事他们最擅长的事情就行了,即编写Java代码;而框架则负责其他繁重的工作,并将Java编译成客户端的JavaScript,从而生成应用程序的前端。
自从在一次测试工作中第一次接触到GWT后,我就被它深深地吸引住了,尽管当时我对它一无所知。自那次之后,我经常与它打交道,但总是为工具而苦恼。但是,是什么让它如此与众不同,我又为什么要定制工具呢?
要了解原因,我们首先需要了解其两个最突出的属性,实际上,正是这两个属性将其与许多其他框架区分开来:
客户端代码
如前所述,GWT应用程序的前端HTML/JavaScript是用Java编写的,并且用到了框架的各种UI API——在这方面,感觉与Java Swing非常类似。不同的是,前端的Java不会被编译成字节码,而是被编译成多个经过混淆的JavaScript代码置换文件(permutation)。每一个置换文件都代表了一个针对特定网络浏览器的优化实现。当应用程序被访问时,引导文件会根据相应的浏览器来加载相应的代码置换文件。
在编译过程中,客户端代码置换文件的混淆处理是默认执行的,这使得从代码审计的角度来看,考察或利用这些置换文件是一个很大的挑战。然而,由于GWT的通信协议的工作方式的缘故,这一挑战并不是不可逾越的。稍后,我们将深入探讨这个问题,现在应该记住的是,客户端和服务器(在GWT应用程序中)总是知道应用程序中到底存在哪些对象、方法和服务。这意味着,如果我们能理解客户端的代码,则很有希望枚举并映射出应用程序的所有功能:无论是可以公开访问的端点,还是经过身份认证才能访问的、更敏感的管理端点。
通信协议
由于前端是用Java编写的,因此,开发过程中自然会用到本地的Java对象。然而,JavaScript是一种完全不同的语言,其中并不存在这些Java对象。因此,在客户机和服务器之间传递数据时,需要设法保留和传输这些信息。
为了解决这个问题,GWT使用了一种名为GWT-RPC的自定义通信协议,该协议会将Java对象序列化,以便在客户端和服务器之间进行传输。然而,与许多其他序列化技术不同的是,GWT-RPC通信中的客户端和服务器都完全知道在任何给定请求/响应期间应该传输的所有对象。这是通过服务策略文件来实现的,该文件存储了所有可序列化对象的列表以及它们在给定服务中的ID。
为了给大家一个直观地感受,并强调GWT-RPC通信与典型的SOAP或REST API的区别,下面给出了一个演示应用程序登录过程中的请求示例:
POST /olympian/authenticationService HTTP/1.1 Host: 127.0.0.1:8888 Content-Type: text/x-gwt-rpc; charset=utf-8 X-GWT-Permutation: 4D2DF0C23D1B58D2853828A450290F3F X-GWT-Module-Base: http://127.0.0.1:8888/olympian/ Content-Length: 251 7|0|7|http://127.0.0.1:8888/olympian/|D48D4639E329B12508FBCA3BD0FC3780| com.ecorp.olympian.client.asyncService.AuthenticationService|login|java .lang.String/2004016611|bob|password123|1|2|3|4|2|5|5|6|7|
下面,我们对这个请求进行详细的说明:
· POST /olympian/authenticationService -> 服务路径
· X-GWT-Permutation:4D2DF0C23D1B58D2853828A450290F3F -> 客户端代码置换文件的“强名称”。
· 7 -> 序列化协议的第7版。
· 0 -> RPC标志值(0/1/2)。
· 7 -> 紧接其后的“字符串表”的长度。
· [STRING TABLE] -> 七个以竖线分隔的字符串,将被用来构建RPC调用。
· 1 -> http://127.0.0.1:8888/olympian/ -> GWT模块的基本URL。
· 2 -> D48D4639E329B12508FBCA3BD0FC3780 -> 服务的策略文件的“强名称”,它指出了哪些对象和类型可以被序列化。
· 3 -> com.ecorp.olympian.client.asyncService.AuthenticationService -> 远程服务接口。
· 4 -> login -> 被调用的方法
· 2 -> 该方法期望的参数数量。
· 5 -> java.lang.String/2004016611 -> 第一个参数的声明类型。
· 5 -> java.lang.String/2004016611 -> 第二个参数的声明类型。
· 6 -> bob -> 第一个参数值在字符串表中的索引。
· 7 -> password123-> 第二个参数值在字符串表中的索引。
以上只是对一个非常简单的请求的快速总结,其中并未包含复杂的Java类型或自定义对象,也不需要区分“声明”和“运行时”类型。我将对此稍作进一步的阐述,但要深入详细地了解GWT-RPC,请参阅Brian Slesinsky的优秀论文“The GWT-RPC wire Protocol”[1]。
现有工具及其局限性
现在,GWT还没有大规模流行,但它已经存在很长时间了。那么,是否已经有人对其进行过安全方面的研究,并创造了相关的工具来帮助进行安全测试呢?是的,是的,已经有了!
GWT渗透测试工具集——GDS(2010)
实际上,早在2010年,Gotham Digital Science团队就发布了“GWT Penetration Testing Toolset(GWT渗透测试工具集)”,其中包括三个工具[2][3]:
· GWTEnum:枚举GWT经过混淆处理的客户端代码中的方法。
· GWTParse:识别GWT-RPC请求payload中的可变参数。
· GWTFuzzer:一个概念验证工具,用Burp自动混淆处理RPC请求。
实际上,GWTEnum正是我所追求的工具类型。然而,它是在2010年编写的,而目前GWT混淆代码的方式已经发生了变化。举个例子,假设我们要使用GWTEnum来搜索置换文件“*.cache.html”,但是,在2.6(2014年发布)版本之前,GWT根本就没有使用过这种文件。相反,较新的版本会生成“*.cache.js”这样的置换文件,并且这些文件具有不同的结构。
GWTEnum的另一个局限性是它无法解析“碎片化”的代码置换文件。碎片化的置换文件是指为了提高性能而分割在多个文件中的代码。因此,它只能根据应用程序的引导文件所加载的初始置换文件来识别应用程序的所有攻击面中的一小部分。
Gwt.py——@steventseeley(2017)
在2017年,Steven Seeley(mr_me)创建了另一个枚举GWT-RPC方法的工具,并尝试为每个方法生成序列化的GWT-RPC请求payload[4][5]。但是,这个工具也对GWT的旧版本有效,并且只适用于未经混淆处理的代码置换文件。此外,尽管可以生成GWT-RPC请求payload,却没有将这些payload与其各自的服务路径或策略文件相关联,而这一点是成功请求任何给定的服务方法来说是必不可少的(至少在版本2.7以上情况如此)。
撇开该工具的局限性不谈,Steven网站上关于GWT的研究是一个非常好的资源,我强烈大家建议阅读这些文献,以便更全面了解GWT-RPC请求的相关结构。
GWTab——TheHackerish(2020)
2020年,TheHackerish创建了一个名为GWTab [6] [7]的Burp Suite扩展,该扩展将GDS套件的GWTParse功能直接移植到了Burp工具中,从而使其能够自动突出显示GWT-RPC payload中的输入值。该工具仍然可以很好地工作,但是只能解析已经录入到Burp中的GWT-RPC请求,并且无法枚举其他/未知方法。
这些年来,研究人员还创建了其他一些工具,但是总的来说,我得出的结论是:
· 对GWT进行了一些很好的研究。
· 已经创建了一些工具,但是所有工具都具有非常特定的用途。
· 遗憾的是,其中大多数工具都无法使用,或者不能与现代版本的GWT(2.7+)一起使用。
解决这些问题
所以,我所追求的是这样一个工具,它可以:
· 枚举所有暴露的GWT方法和服务;
· 解析经过混淆处理的和没有经过混淆处理的代码置换文件;
· 检测排列是否被碎片化,并找到所有缺失的碎片;
· 将方法与其服务路径和策略文件关联起来;
· 生成GWT-RPC POST请求实例;
· 适用于所有[?]现代版本(2.7版本以上)的GWT。
我们决定迎难而上,首先要解决的第一件事情,就是理解经过混淆处理的置换文件,并确定哪些模式可以用来枚举数据。
从经过混淆处理的代码中提取出方法签名
首先,我编写了一个测试用的GWT应用程序,然后,对它进行了非混淆型的编译,并研究了生成的JavaScript代码中“login”方法调用的具体实现。在这里,我们之所以要从非混淆版的代码开始下手,是因为我们可以把它作为一个简单的基准,以便将来审查混淆版的代码的时候,可以对两者进行比较。
在展示生成的JavaScript之前,请注意“login”方法是“AuthenticationService”服务的一部分,其Java接口定义如下所示:
@RemoteServiceRelativePath("authenticationService") public interface AuthenticationService extends RemoteService{ User login(String username, String password); ... }
考虑到这一点,我们就可以在下面的客户端JavaScript代码中找到生成的login方法调用的实现:
function $login_0(this$static, username, password, callback){ var helper, streamWriter; helper = new RemoteServiceProxy$ServiceHelper(this$static, 'AuthenticationService_Proxy', 'login'); try { streamWriter = $start(helper, 'com.ecorp.olympian.client.asyncService.AuthenticationService', 2); $append(streamWriter, '' + $addString(streamWriter, 'java.lang.String/2004016611')); $append(streamWriter, '' + $addString(streamWriter, 'java.lang.String/2004016611')); $append(streamWriter, '' + $addString(streamWriter, username)); $append(streamWriter, '' + $addString(streamWriter, password)); $finish_0(helper, callback, ($clinit_RequestCallbackAdapter$ResponseReader() , OBJECT)); } catch ($e0) { $e0 = toJava($e0); if (!instanceOf($e0, 12)) throw toJs($e0); } }
那么,我们从中看到了些什么呢?
· 我可以看到,所有的服务端点方法都有自己的函数。
· 该函数的第二行代码声明了服务代理(“AuthenticationService_Proxy”)以及方法名(“login”)。
· 在这之后,还有一个try-catch语句,其中第一行声明了远程服务接口值(“com.exorp.olympian.client.asycnService.AuthenticationService”)和方法期望的参数数量(2)。
· 紧接着,是偶数个函数调用(不包括“$finish_0”),其中前半部分声明方法参数类型(“java.lang.String”) ,后半部分声明参数值(username, password)。
进一步审查后发现,所有方法的情况都基本一致。所以,我们以此为基础,但是使用默认模式(即使用混淆处理)重新编译了该应用程序,并逆向分析了经过混淆处理的客户端代码。经过一番折腾后,我们发现上面“$login_0”函数在混淆版中变成了一个叫做“Cd”的函数,具体如下图所示:
遗憾的是,目前尚无法确定上面的代码就是login函数的实现代码,而且原来非混淆版的代码中的换行和缩进信息也全部消失了。为了弄清楚这些问题,我使用python语言编写了一个“clean_code()”函数,以编程方式在经过混淆处理后的代码中重新插入换行符和缩进符。
利用新工具处理相关代码后,我惊奇的发现:经过混淆处理的代码与原始函数在结构上几乎是完全吻合的:
function Cd(b,c,d,e){ var f,g; f=new yu(b,iH,'login'); try{ g=xu(f,jH,2); nu(g,''+cu(g,cH)); nu(g,''+cu(g,cH)); nu(g,''+cu(g,c)); nu(g,''+cu(g,d)); wu(f,e,(Nu(),Ju)) } catch(a){ a=Xq(a); if(!bl(a,12))throw Yq(a) } }
这里的复杂之处在于,一些原始的字符串值现在变成了缩小版的变量和嵌套的函数调用。但是,如果我们能够识别“方法签名”(例如,“f=new yu(b,iH,'login');”)并添加换行符,就可以推断出其他key value位于哪个偏移量处。然后,我还可以使用正则表达式以编程方式识别嵌套变量和函数的值。
起初,我非常担心,因为代码中通常有大量重复的局部变量名称。然而,最初硬编码的字符串值(作为这些代码行的一部分)似乎总是全局变量。所有的全局变量都被声明到置换文件的顶部。例如,我可以通过下面置换文件中的相关内容来提取“ih”全局变量的值来找到服务代理的原始值:
经过一天的折腾,借助于逆向分析、python脚本和各种正则表达式,我成功地枚举出了经过混淆处理的置换文件中的所有方法及其关联的参数类型:
$ ./wip.py -u "http://192.168.22.120/olympian/037A330198815EAE6A360B7107F8C442.cache.js" ... AuthenticationService.login( java.lang.String/2004016611, java.lang.String/2004016611 ) AuthenticationService.logout( com.ecorp.olympian.shared.model.ContextId/1042941669 ) ... AccountService.getBio( com.ecorp.olympian.shared.model.ContextId/1042941669 ) AccountService.requestPasswordReset( java.lang.String/2004016611 ) ... TestService.testStringAndBoolean( java.lang.String/2004016611, java.lang.Boolean/476441737 ) TestService.testStringAndByte( java.lang.String/2004016611, B ) TestService.testStringAndChar( java.lang.String/2004016611, C ) ...
您可能已经注意到上面的输出中的参数类型“B”和“C”了,我将在稍后对其进行解释。
太棒了,我们又获得了2010年发行的GWTEnum工具所具备的功能了,并且可以处理2020年的GWT!
尝试匹配所有可能的代码混淆变体
在兴奋之余,我对一些用不同GWT版本编写的应用程序的置换文件进行了测试,结果……对任何一个应用程序都不起作用。
事实证明,代码混淆方式并不总是一成不变的。事实上,代码混淆的方式有很多不同的变体。因此,虽然我最初的正则表达式对我的应用程序有效,但对其他许多应用程序却不起作用。
为了尝试解决这个问题,我采集了更多不同GWT变种的样本,找到了它们的共同模式,并更新了我的(噩梦般的)正则表达式来匹配和区分它们。为了便于理解,我们将通过几个例子来说明代码的不同部分是如何通过不同的GWT置换文件进行表示的。
方法签名模式
虽然方法签名是最简单的,但是,它们的组成成分却是多变的:有时它们仅由变量组成,有时它们含有硬编码字符串,并且用于变量名称的字符集和长度也各不相同:
helper = new RemoteServiceProxy$ServiceHelper(this$static, 'AuthenticationService_Proxy', 'login'); e=new yu(b,zH,YG); g=new yu(b,_G,'login'); k=new q8(this,Sqc,Llc); j=new q8(this,Sqc,'login');
方法参数模式
方法参数的变化较大。变量值经常嵌套在函数中,硬编码值有多种不同的格式。特别是布尔值,有时被表示为三元语句,这是我的正则表达式完全没有想到的:
$append(streamWriter, '' + $addString(streamWriter, username)); TK(i,c); aL(i,''+QK(i,d)); O7(h,M7(h,c)); c8(h.a,d?'1':'0'); nu(f,''+cu(f,'hardcoded')); su(f.a,'5.699999809265137');
远程服务接口模式
到目前为止,变化最大的实例之一就是远程服务接口得定义。有时它们只是一个函数,但更多的时候它们是一个用于初始化本地变量的函数。其他时候,它们则有很大的不同,比如嵌套在一些复杂的长字符串里面,与其他模式的偏移量完全不同:
streamWriter = $start(helper, 'com.some.example.client.service', 2); h=(e9()&&f9(g9(g.c,g.a,jlc)),g.d=g.e.mg(),Q7(g.d,'com.some.example.client.service'),Q7(g.d,g.b),O7(g.d,2),g.d); g=(e9()&&f9(g9(f.c,f.a,jlc)),f.d=f.e.mg(),Q7(f.d,irc),Q7(f.d,f.b),O7(f.d,2),f.d); wbk(e,'com.some.example.client.service',2); g=wbk(f,'com.some.example.client.service',2); g=wbk(f,pFl,2);
服务定义模式
服务定义相对一致。但是,一个关键的区别是,有时会使用前端的编写方式,这时服务路径不会包括在定义“行”本身中,因此只能提取策略文件的强名称:
RemoteServiceProxy.call(this, getModuleBaseURL(), 'authenticationService', 'D48...780', SERIALIZER_0);
dd.call(this,ng(),'authenticationService','D48D4639E329B12508FBCA3BD0FC3780',zd)
nL.call(this,Am(),'../example/authenticationService','D48D4639E329B12508FBCA3BD0FC3780',x3)
m8.call(this,Lk(),'D48D4639E329B12508FBCA3BD0FC3780',snb)
这些信息在服务定义方面的限制将是以后所要面临的一个挑战,因为即使在最好的情况下,只能枚举出服务路径和策略强名称;而在更糟糕的情况下,只能枚举策略强名称。但是,除非服务的相对服务路径与方法的远程服务接口定义中的服务名称相匹配,否则这两个值都不会与方法定义中的信息直接相关。然而,我发现情况并不总是如此。
但不管怎样,通过一些额外的正则表达式技巧,我能够一致地从不同应用程序代码置换文件的完整示例中提取几乎所有的方法和服务信息。
注意,上面使用了“几乎”这个词!?也就是说还是会漏掉了一些吗?是的,这是代码碎片化所致!?
小结
在本文中,我们将为读者详细介绍如何利用GWTMap工具对GWT应用进行安全审查,由于篇幅较大,我们分为上下两篇进行介绍。更多精彩内容,敬请期待。
本文翻译自:https://labs.f-secure.com/blog/gwtmap-reverse-engineering-google-web-toolkit-applications/如若转载,请注明原文地址: