2018年4月,Android安全公告公布了CVE-2017-13287漏洞。
與同期披露的其他漏洞一起,同屬于框架中Parcelable對象的寫入(序列化)與讀出(反序列化)的不一致所造成的漏洞。
在剛看到谷歌對于漏洞給出的補丁時一頭霧水,
在這里要感謝heeeeen@MS509Team在這個問題上的成果,啟發了我的進一步研究。
原理
谷歌在Android中提供了Parcelable作為高效的序列化實現,用來支持IPC調用中多樣的對象傳遞需求。
但是序列化和反序列化的過程依舊依靠程序員編寫的代碼進行同步。
那么當不同步的時候,漏洞就產生了。
Bundle
傳輸的時候Parcelable對象按照鍵值對的形式存儲在Bundle內,Bundle內部有一個ArrayMap用hash表進行管理。
反序列化過程如下:
/* package */ void unparcel() { synchronized (this) { final Parcel parcelledData = mParcelledData; int N = parcelledData.readInt(); if (N < 0) { return; } ArrayMap<String, Object> map = mMap; try { parcelledData.readArrayMapInternal(map, N, mClassLoader); } catch (BadParcelableException e) { } finally { mMap = map; parcelledData.recycle(); mParcelledData = null; } } }
首先讀取一個int指示里面有多少對鍵值對。
/* package */ void readArrayMapInternal(ArrayMap outVal, int N, ClassLoader loader) { if (DEBUG_ARRAY_MAP) { RuntimeException here = new RuntimeException("here"); here.fillInStackTrace(); Log.d(TAG, "Reading " + N + " ArrayMap entries", here); } int startPos; while (N > 0) { if (DEBUG_ARRAY_MAP) startPos = dataPosition(); String key = readString(); Object value = readValue(loader); outVal.Append(key, value); N--; } outVal.validate(); }
之后的每一對先是Key的字符串,然后是對應的Value。
public final Object readValue(ClassLoader loader) { int type = readInt(); switch (type) { case VAL_NULL: return null; case VAL_STRING: return readString(); case VAL_INTEGER: return readInt(); case VAL_MAP: return readHashMap(loader); case VAL_PARCELABLE: return readParcelable(loader); case VAL_SHORT: return (short) readInt(); case VAL_LONG: return readLong();
值內部先是一個int指示值的類型,再存儲實際值。
當Bundle被寫入Parcel時:
void writeToParcelInner(Parcel parcel, int flags) { final ArrayMap<String, Object> map; synchronized (this) { if (mParcelledData != null) { if (mParcelledData == NoImagePreloadHolder.EMPTY_PARCEL) { parcel.writeInt(0); } else { int length = mParcelledData.dataSize(); parcel.writeInt(length); parcel.writeInt(BUNDLE_MAGIC); parcel.appendFrom(mParcelledData, 0, length); } return; } map = mMap; } }
先寫入Bundle總共的字節數,再寫入魔數,之后是指示鍵值對數的N,還有相應的鍵值對。
LaunchAnyWhere
弄明白Bundle的內部結構后,先來看看漏洞觸發的地方:

這個流程是AppA在請求添加一個帳號:
- AppA請求添加一個帳號
- System_server接受到請求,找到可以提供帳號服務的AppB,并發起請求
- AppB返回了一個Bundle給系統,系統把Bundle轉發給AppA
- AccountManagerResponse在AppA的進程空間中調用startActivity(intent)調起一個Activity。
在第4步中,如果AppA的權限較高,比如Settings,那么AppA可以調用正常App無法調用的未導出Activity。
并且在第3步中,AppB提供的Bundle在system_server端被反序列化,之后system_server根據之前得到的內容再序列化并傳遞給AppA。
那么如果對應的傳遞內容的序列化和反序列化代碼不一樣,就會影響到自己以及之后的內容的結果。
傳遞的Bundle對象中包含一個重要鍵值對{KEY_INTENT:intent},指定了AppA稍后調用的Activity。
如果這個被指定成Setting中的com.android.settings.password.ChooseLockPassword,就可以在不需要原本鎖屏密碼的情況下重新設置鎖屏密碼。
谷歌在這個過程中進行了檢查,保證Intent中包含的Activity所屬的簽名和AppB一致,并且不是未導出的系統Actiivity。
protected void checkKeyIntent(int authUid, Intent intent) throws SecurityException { long bid = Binder.clearCallingIdentity(); try { PackageManager pm = mContext.getPackageManager(); ResolveInfo resolveInfo = pm.resolveActivityAsUser(intent, 0, maccounts.userId); ActivityInfo targetActivityInfo = resolveInfo.activityInfo; int targetUid = targetActivityInfo.applicationInfo.uid; if (!isExportedSystemActivity(targetActivityInfo) && (PackageManager.SIGNATURE_MATCH != pm.checkSignatures(authUid, targetUid))) { String pkgName = targetActivityInfo.packageName; String activityName = targetActivityInfo.name; String tmpl = "KEY_INTENT resolved to an Activity (%s) in a package (%s) that " + "does not share a signature with the supplying authenticator (%s)."; throw new SecurityException( String.format(tmpl, activityName, pkgName, mAccountType)); } } finally { Binder.restoreCallingIdentity(bid); }}
攻擊思路便是在system_server進行檢查時Bundle中的惡意{KEY_INTENT:intent}看不到,但是在重新序列化之后在Setting出現,這樣就繞過了檢查。
利用
首先來看看漏洞所在的代碼
public static final Parcelable.Creator<VerifyCredentialResponse> CREATOR = new Parcelable.Creator<VerifyCredentialResponse>() { @Override public VerifyCredentialResponse createFromParcel(Parcel source) { int responseCode = source.readInt(); VerifyCredentialResponse response = new VerifyCredentialResponse(responseCode, 0, null); if (responseCode == RESPONSE_RETRY) { response.setTimeout(source.readInt()); } else if (responseCode == RESPONSE_OK) { int size = source.readInt(); if (size > 0) { byte[] payload = new byte[size]; source.readByteArray(payload); response.setPayload(payload); } } return response; } @Override public VerifyCredentialResponse[] newArray(int size) { return new VerifyCredentialResponse[size]; } }; @Override public void writeToParcel(Parcel dest, int flags) { dest.writeInt(mResponseCode); if (mResponseCode == RESPONSE_RETRY) { dest.writeInt(mTimeout); } else if (mResponseCode == RESPONSE_OK) { if (mPayload != null) { dest.writeInt(mPayload.length); dest.writeByteArray(mPayload); } } }
仔細閱讀,會發現在mResponseCode為RESPONSE_OK時,
如果mPayload為null,那么writeToParcel不會在末尾寫入0來正確的指示Payload部分的長度。
而在createFromParcel中是需要readInt來獲知的,這個就帶來了序列化與反序列化過程的不一致。
可以通過精心構造的payload來繞過檢查。
難點在于和已經有人公開過的CVE-2017-13288和CVE-2017-13315不同,
它們是重新序列化之后會多出來4個字節。這里是重新序列化之后會少4個字節。
】

利用String的結構,把惡意intent隱藏在String里。上圖每段注釋的括號里寫了其所占用的字節數。
在第一次反序列化時,VerifyCredentialResponse內部的0還在,惡意intent被包裝在第二對的Key中。
第二對的值的類型被制定為VAL_NULL,也就是什么都沒有,常量值為-1。
再次序列化時writeToParcel沒有writeInt(0),所以到達Setting的Bundle在RESPONSE_OK之后沒有0,原本的String length被視作payload length,調用readByteArray讀取。
static jbyteArray android_os_Parcel_createByteArray(JNIEnv* env, jclass clazz, jlong nativePtr) { jbyteArray ret = NULL; Parcel* parcel = reinterpret_cast<Parcel*>(nativePtr); if (parcel != NULL) { int32_t len = parcel->readInt32(); // sanity check the stored length against the true data size if (len >= 0 && len <= (int32_t)parcel->dataAvail()) { ret = env->NewByteArray(len); if (ret != NULL) { jbyte* a2 = (jbyte*)env->GetPrimitiveArrayCritical(ret, 0); if (a2) { const void* data = parcel->readInplace(len); memcpy(a2, data, len); env->ReleasePrimitiveArrayCritical(ret, a2, 0); } } } } return ret;}
再次調用readInt32讀取長度,之后截取數組內容。相應的從Payload length開始的指定長度的內容都被視作payload。
只要設置得當,惡意intent就會顯露出來成為實質上的第二對鍵值對。
那么之前作為第二對值的VAL_NULL怎么辦?之前提過它的常量值是-1,上一對惡意intent剛結束,在這里調用的是readString這個函數。
const char16_t* Parcel::readString16Inplace(size_t* outLen) const{ int32_t size = readInt32(); // watch for potential int overflow from size+1 if (size >= 0 && size < INT32_MAX) { *outLen = size; const char16_t* str = (const char16_t*)readInplace((size+1)*sizeof(char16_t)); if (str != NULL) { return str; } } *outLen = 0; return NULL;}
再次的readInt32,得到-1,直接返回null,長度為0,會在JNI層中創建一個空字符串返回到JAVA層。那么就是說:VAL_NULL單獨作為一個空字符串被讀取,之后的三個藍色塊被視作值。
這里因為之后的字符串是123456,所以string_length是6.
這個很關鍵,因為在Settings這里被readValue視作type,而6正好是VAL_STRING,也即字符串類型。于是ord('1')= 0x31被視作String length正常使用,正常讀取字符串。
至此Settings側正常讀取完畢,惡意intent被讀取并執行。
假String的構造
之前略過了包含惡意intent的假String的具體padding過程,這里展開:
String_length(4) + Payload_length(4) + PADDING(Size + 16) + EVIL_INTENT(Size) + PADDING(8)String_length = Payload_length = (4 + 4 + Size + 16 + Size + 8) / 2 – 1 = Size + 15
這里先給出公式,Size在這里就是Evil_intent部分的長度,String_length和Payload_length在Setting側都被視作payload的長度使用,故相同。
從兩個視角去審視這個公式:
- system_server側
對于system_server來說,從String_length開始的部分就是單純的一個字符串,那么它先讀取String_length并套用readString16Inplace中的公式。
它會從String_length之后讀取$ 2(1 + Size + 15)=2Size + 32 $,正好包括總長。
- Settings側
對于Settings來說,從Payload_length之后會直接截取對應長度的內容作為數組,即Payload_length之后$Size + 15$,
因為Parcel底層的操作對4向上湊整,所以正好露出EVIL_INTENT。
這樣就可以達成效果。
結果
POC: https://github.com/FXTi/CVE201713287POC
總結
在IPC這塊就算谷歌引入了AIDL這種方式來規定接口,哪怕只是中間所用到的類的序列化過程出現一點失誤都會造成如此嚴重的漏洞。
可見安全編程以及代碼審計的必要性,沒準以后還會有類似機理的漏洞被發掘出來。
作者:FXTi
轉載自 https://www.anquanke.com/post/id/197710