本系列文章共十篇:
【爬蟲(chóng)成長(zhǎng)之路】(一)爬蟲(chóng)系列文章導(dǎo)讀
【爬蟲(chóng)成長(zhǎng)之路】(二)各篇需要用到的庫(kù)和工具
【爬蟲(chóng)成長(zhǎng)之路】(三)【大眾點(diǎn)評(píng)】selenium爬蟲(chóng)
【爬蟲(chóng)成長(zhǎng)之路】(四)【大眾點(diǎn)評(píng)】selenium登錄+requests爬取數(shù)據(jù)
【爬蟲(chóng)成長(zhǎng)之路】(五)【大眾點(diǎn)評(píng)】瀏覽器掃碼登錄+油猴直接爬取數(shù)據(jù)
【爬蟲(chóng)成長(zhǎng)之路】(六)【大眾點(diǎn)評(píng)】mitmproxy修改HttpOnly字段獲取完整cookie+requests請(qǐng)求數(shù)據(jù)
【爬蟲(chóng)成長(zhǎng)之路】(七)【大眾點(diǎn)評(píng)】PC微信小程序+requests爬取數(shù)據(jù)
【爬蟲(chóng)成長(zhǎng)之路】(八)【大眾點(diǎn)評(píng)】安卓APP爬蟲(chóng)
本章標(biāo)題是安卓APP爬蟲(chóng)竞滓,說(shuō)實(shí)話对雪,如果爬蟲(chóng)的攻防對(duì)抗升級(jí)到了APP層面笋婿,這差不多是爬蟲(chóng)的最高形態(tài)了,之所以會(huì)升級(jí)到APP層面佳励,如果有把前面的文章中的實(shí)驗(yàn)都做一遍,就不難發(fā)現(xiàn),雖然爬蟲(chóng)能爬取數(shù)據(jù)熊尉,但是爬不了多少數(shù)據(jù)就會(huì)被封膏潮,所以如果需要更多的數(shù)據(jù)锻狗,就不得不轉(zhuǎn)移到APP這個(gè)層面來(lái)。APP能夠爬取數(shù)據(jù)的前提是目標(biāo)APP不強(qiáng)制要求登錄焕参,如果強(qiáng)制要求登錄轻纪,那APP端也爬不動(dòng)。幸運(yùn)的是大眾點(diǎn)評(píng)APP并沒(méi)有強(qiáng)制要求登錄叠纷,所以我們可以從APP端入手刻帚。
本文需要用到的工具:
Fiddler
、IDA
涩嚣,JADX-gui
崇众、frida
、objection
航厚、已root安卓手機(jī)或安卓模擬器顷歌、大眾點(diǎn)評(píng)APP v10.41.15...
本文需要用到的庫(kù):requests
...
這里對(duì)這幾個(gè)工具作個(gè)簡(jiǎn)單的介紹:
- Fiddler:HTTP/HTTPS抓包軟件,可重放請(qǐng)求幔睬,也可修改請(qǐng)求和響應(yīng)眯漩;
- IDA:反編譯工具,可以將二進(jìn)制文件反編譯成匯編或偽代碼麻顶,還可以動(dòng)態(tài)調(diào)試赦抖,功能十分強(qiáng)大舱卡;
- JADX-gui:可以將APK直接反編譯成JAVA代碼,絕大部分都能還原回來(lái)队萤,最重要的是可以Go to Definition灼狰,還可以查找引用,這個(gè)非常好用浮禾;
- frida:一款可以使用JS進(jìn)行HOOK的全平臺(tái)的框架交胚,使用非常簡(jiǎn)單,無(wú)需配置盈电;
- objection:基于frida開(kāi)發(fā)的使用命令行進(jìn)行HOOK的工具蝴簇,不用寫(xiě)代碼就能完成HOOK工作,十分好用匆帚。
如果frida和objection不會(huì)使用的同學(xué)熬词,可以參考下官方文檔和下面的這些文章:
- Frida 安裝和使用
- FRIDA系列文章
- 實(shí)用FRIDA進(jìn)階:內(nèi)存漫游、hook anywhere吸重、抓包
- frida入門總結(jié)
- 一篇文章帶你領(lǐng)悟Frida的精髓(基于安卓8.1)
- 雷電模擬器安裝frida-server教程
- Frida Android hook
- objection 常用方法
- 記一次APP加密通信后的分析過(guò)程
- Frida構(gòu)造Java函數(shù)所需的Map<String, List<String>>參數(shù)
一互拾、需求分析
這一篇總共需要爬取兩個(gè)頁(yè)面的數(shù)據(jù),分別是:
- 某用戶的
評(píng)論詳情
頁(yè)面
二嚎幸、獲取目標(biāo)頁(yè)面的URL
由于是需要從APP入手颜矿,第一步還是抓包,對(duì)普通應(yīng)用程序來(lái)說(shuō)嫉晶,只需要在手機(jī)上安裝好證書(shū)骑疆,連上電腦的WIFI,就能在PC端抓到手機(jī)的包替废,但是在抓大眾點(diǎn)評(píng)的時(shí)候你會(huì)發(fā)現(xiàn)可以抓到一些提交日志信息相關(guān)的包箍铭,實(shí)際上有數(shù)據(jù)的包一個(gè)也抓不到,這是什么原因呢椎镣?
面對(duì)PC抓不到手機(jī)的包诈火,在手機(jī)和PC配置都沒(méi)有出錯(cuò)的前提下,一般有以下這兩種情況:
- APP檢測(cè)到了使用代理了状答,直接拒絕工作冷守,此時(shí)APP上不會(huì)更新任何數(shù)據(jù);
- APP所采用的不是HTTP/HTTPS通信協(xié)議,導(dǎo)致Fiddler抓不到包,但是使用wireshark可以看到明顯是有產(chǎn)生數(shù)據(jù)包的死宣。
具體是哪一種造锅,在使用過(guò)程中,我們可以看到大眾點(diǎn)評(píng)的APP明明有新的評(píng)論數(shù)據(jù)刷新出來(lái),但是Fiddler里面確是一條數(shù)據(jù)都沒(méi)有风喇,而使用wireshark的時(shí)候卻可以明顯看到有數(shù)據(jù)包產(chǎn)生怪与,這說(shuō)明大眾點(diǎn)評(píng)APP使用的不是HTTP/HTTPS協(xié)議堪唐,而是使用了TCP協(xié)議或者自定義的協(xié)議巡语。在逆向大眾點(diǎn)評(píng)的APP之前,我們先去search一下淮菠,在 美團(tuán)點(diǎn)評(píng)移動(dòng)網(wǎng)絡(luò)優(yōu)化實(shí)踐看到了這張圖男公,這也印證了大眾點(diǎn)評(píng)并不是在使用HTTP通信。從實(shí)戰(zhàn)報(bào)告中我們也可以知道合陵,他們提供了3種方案用于完成通信枢赔。
美團(tuán)技術(shù)團(tuán)隊(duì):圖中網(wǎng)絡(luò)通道SDK包含了三大通信通道:
- CIP通道:CIP通道就是上文中提到的自建代理長(zhǎng)連通道。CIP是China Internet Plus的縮寫(xiě)拥知,為美團(tuán)點(diǎn)評(píng)集團(tuán)的注冊(cè)英文名稱踏拜。App中絕大部分的請(qǐng)求通過(guò)CIP通道中的TCP子通道與長(zhǎng)連服務(wù)器(CIP Connection Server)通信,長(zhǎng)連服務(wù)器將收到的請(qǐng)求代理轉(zhuǎn)發(fā)到業(yè)務(wù)服務(wù)器(API Server)低剔。由于TCP子通道在一些極端情況下可能會(huì)無(wú)法工作速梗,我們?cè)贑IP通道中額外部署了UDP子通道和HTTP子通道,其中HTTP子通道通過(guò)公網(wǎng)繞過(guò)長(zhǎng)連服務(wù)器與業(yè)務(wù)服務(wù)器進(jìn)行直接請(qǐng)求襟齿。CIP通道的平均端到端成功率目前已達(dá)99.7%姻锁,耗時(shí)平均在350毫秒左右。
- WNS通道:出于災(zāi)備的需要猜欺,騰訊的WNS目前仍被包含在網(wǎng)絡(luò)通道SDK中位隶。當(dāng)極端情況發(fā)生,CIP通道不可用時(shí)替梨,WNS通道還可以作為備用的長(zhǎng)連替代方案钓试。
- HTTP通道:此處的HTTP通道是在公網(wǎng)直接請(qǐng)求API Server的網(wǎng)絡(luò)通道。出于長(zhǎng)連通道重要性的考慮副瀑,上傳和下載大數(shù)據(jù)包的請(qǐng)求如果放在長(zhǎng)連上進(jìn)行都有可能導(dǎo)致長(zhǎng)連通道的擁堵,因此我們將CDN訪問(wèn)恋谭、文件上傳和頻繁的日志上報(bào)等放在公網(wǎng)利用HTTP短連進(jìn)行請(qǐng)求糠睡,同時(shí)也減輕代理長(zhǎng)連服務(wù)器的負(fù)擔(dān)。
到此為止我們有兩種方法可以完成我們的爬蟲(chóng):
- 使用TCP通道進(jìn)行爬取疚颊,需要逆向APP狈孔;
- 使用HTTP通道;
但是在TCP通道可用的情況下材义,APP是不會(huì)采用HTTP通信的(上文提到均抽,HTTP通常只用來(lái)上傳日志和下載圖片),有數(shù)據(jù)的內(nèi)容不會(huì)通過(guò)HTTP進(jìn)行傳輸其掂,所以我們可以想辦法阻塞掉TCP通道油挥。一開(kāi)始我選用的方案是在wireshark中查看目標(biāo)服務(wù)器的IP,然后在windows的防火墻里封禁相應(yīng)的IP,在嘗試多次之后深寥,方向他的IP實(shí)在是太多了攘乒。。惋鹅。大概禁了七八個(gè)吧则酝,然后發(fā)現(xiàn)APP上的數(shù)據(jù)不更新了,可能HTTP也是走的這些通道吧闰集,或者根本就沒(méi)降級(jí)到HTTP沽讹。。武鲁。這個(gè)我不確定妥泉,沒(méi)仔細(xì)看,有興趣的同學(xué)可以自行驗(yàn)證洞坑。
那如果需要抓到HTTP的包盲链,就必須對(duì)APP進(jìn)行逆向了,這里我使用的是JADX-gui這款軟件迟杂,電腦內(nèi)存要大些才好刽沾,真的是有多少內(nèi)存就吃多少,最少也留個(gè)3G的空閑內(nèi)存吧排拷。 這里怎么逆向APP我就不展開(kāi)講解了侧漓,網(wǎng)上有蠻多的教程,如果不會(huì)的話自行去百度一下吧监氢。這個(gè)說(shuō)個(gè)比較重要的思路布蔗,如果老版本的APP可以使用,那就盡量從老版本的APP開(kāi)始入手浪腐,這樣遇到各種加密和混淆的概率低一些纵揍,也方便入手。
在逆向完成后议街,能夠看到j(luò)adx-gui反編譯后的JAVA代碼了泽谨,現(xiàn)在要做的就是根據(jù)一些關(guān)鍵字去定位到相應(yīng)的代碼,比如我們知道點(diǎn)評(píng)的域名信息是**.dianping.com/**
特漩,就可以從中搜索了吧雹,定位代碼和理清其邏輯關(guān)系,這一步是這篇教程中最費(fèi)時(shí)的一步涂身。這里具體如何操作雄卷,我就不演示了,反正挺費(fèi)時(shí)間的蛤售,需要有耐心丁鹉。
下面給幾個(gè)關(guān)鍵的步驟妒潭,以便讀者可以快速的獲取到相關(guān)的接口:
我們知道傳輸?shù)臄?shù)據(jù)都是經(jīng)過(guò)加密了的,所以我們可以從加密函數(shù)入手鳄炉,利用frida來(lái) HOOK相應(yīng)的接口即可杜耙,
// 源程序包路徑:com.dianping.nvnetwork.tunnel.tool.c
private static Key a(byte[] bArr) throws Exception {
Object[] objArr = {bArr};
ChangeQuickRedirect changeQuickRedirect = a;
if (PatchProxy.isSupport(objArr, null, changeQuickRedirect, true, "862c9f994092c26eb84b9d83c437a3ac", RobustBitConfig.DEFAULT_VALUE)) {
return (Key) PatchProxy.accessDispatch(objArr, null, changeQuickRedirect, true, "862c9f994092c26eb84b9d83c437a3ac");
}
return SecretKeyFactory.getInstance("DES").generateSecret(new DESKeySpec(bArr));
}
public static byte[] a(byte[] bArr, byte[] bArr2) throws Exception {
Object[] objArr = {bArr, bArr2};
ChangeQuickRedirect changeQuickRedirect = a;
if (PatchProxy.isSupport(objArr, null, changeQuickRedirect, true, "f7a471aa6ce2c3ab09d975bac8d088d0", RobustBitConfig.DEFAULT_VALUE)) {
return (byte[]) PatchProxy.accessDispatch(objArr, null, changeQuickRedirect, true, "f7a471aa6ce2c3ab09d975bac8d088d0");
}
Key a2 = a(bArr2);
Cipher instance = Cipher.getInstance("DES");
instance.init(2, a2);
return instance.doFinal(bArr);
}
// 通過(guò)對(duì)所有的加密函數(shù)進(jìn)行HOOK,最終發(fā)現(xiàn)所有的URL相關(guān)加密都會(huì)經(jīng)過(guò)這個(gè)函數(shù)拂盯,因此HOOK這個(gè)函數(shù)
public static byte[] b(byte[] bArr, byte[] bArr2) throws Exception {
Object[] objArr = {bArr, bArr2};
ChangeQuickRedirect changeQuickRedirect = a;
if (PatchProxy.isSupport(objArr, null, changeQuickRedirect, true, "791dd5351de3cfac5e41752ae0c020dc", RobustBitConfig.DEFAULT_VALUE)) {
return (byte[]) PatchProxy.accessDispatch(objArr, null, changeQuickRedirect, true, "791dd5351de3cfac5e41752ae0c020dc");
}
Key a2 = a(bArr2);
Cipher instance = Cipher.getInstance("DES");
instance.init(1, a2);
return instance.doFinal(bArr);
}
//frida js HOOK 代碼
function bin2string(array){
var result = "";
for(var i = 0; i < array.length-1; ++i){
result+= (String.fromCharCode(array[i]));
}
return result;
}
function main(){
Java.perform(function x() {
var c = Java.use("com.dianping.nvnetwork.tunnel.tool.c");
c.b.overload("[B","[B").implementation = function(bArr, bArr2){
var result = this.b(bArr, bArr2);
var str = bin2string(bArr);
if(str.includes("pragma-unionid") && str.includes("pragma-dpid") && str.includes("mainid")){
console.log(str);// 打印傳入待加密的參數(shù)
}
return result;
}
}
}
setImmediate(main)
通過(guò)HOOK DES加密函數(shù)佑女,最終找到相關(guān)接口及Header信息,Header及其他被加密的信息如下:
{
"m": "GET",
"h": {
"pragma-device": "4***3",//IMEI
"network-type": "wifi",
"pragma-os": "MApi 1.4 (com.dianping.v1 10.36.3 om_sd_** NXT-DL00; Android 8.0)",//類似于UserAgent
"pragma-uuid": "11139***2",//UUID可用UUID生成算法生成
"pragma-unionid": "158bca***83",// 需要向服務(wù)器請(qǐng)求獲得
"User-Agent": "MApi 1.4 (com.dianping.v1 10.36.3 om_sd_** NXT-DL00; Android 8.0)",
"pragma-dpid": "158bca***83",// 需要向服務(wù)器請(qǐng)求獲得
"picasso": "no-js",
"M-SHARK-TRACEID": "11158b***a"http://本地生成
},
"u": "http://***", // 目標(biāo)URL
"i": "11***4"http:// 請(qǐng)求序號(hào)
}
其實(shí)到這里雖然拿到了這些重點(diǎn)參數(shù)谈竿,但是此時(shí)仍然是建立了TCP連接而不是HTTP連接团驱,所以,直接用這些數(shù)據(jù)去請(qǐng)求的話必然是失敗的空凸,這里就要迫使APP降級(jí)采用HTTP連接了嚎花,這里參考了github的一位大佬的代碼,我找了好一會(huì)沒(méi)找到入口在哪呀洲。紊选。。
//frida JS代碼道逗,繞過(guò)CIP和WNS代理兵罢,直接走HTTP通道
var nvnetwork_g = Java.use("com.dianping.nvnetwork.g");
nvnetwork_g.g.overload().implementation = function(){
console.log("----------------------------- Hook g()---------------------------");
return 3;
}
這樣就可以使用Fiddler抓包了,最終結(jié)果如下滓窍。
評(píng)論詳情URL接口:
# 評(píng)論詳情URL接口:
http://mapi.dianping.com/mapi/note/getfeedcontent.bin?***
三卖词、請(qǐng)求頭Header、URL參數(shù)解析
評(píng)論URL參數(shù)
這里有兩個(gè)重點(diǎn)參數(shù)吏夯,分別是mainid
和 cx
此蜈,這個(gè)cx
和小程序里的cx
是不一樣的,這點(diǎn)需要注意一下噪生。
序號(hào) | 名稱 | 值 | 說(shuō)明 |
---|---|---|---|
1 | mainid |
*** | 評(píng)論ID |
2 | feedtype | 1 | 評(píng)論類型 |
3 | lng | *** | 經(jīng)度 |
4 | lat | *** | 緯度 |
5 | displaypattern | 2 | 顯示模式裆赵,固定 |
6 | bubblepagetype | null | -,固定 |
7 | cx |
*** | 加密生成的參數(shù)杠园,帶了時(shí)間戳顾瞪,重點(diǎn)參數(shù) |
8 | pagecityid | 1 | -,固定 |
9 | optimus_partner | 76 | -抛蚁,固定 |
10 | optimus_risk_level | 71 | 風(fēng)險(xiǎn)等級(jí),固定 |
11 | optimus_code | 10 | -惕橙,固定 |
12 | picsize | ... | 一些分辨率信息瞧甩,可適當(dāng)調(diào)整 |
Header 參數(shù)
看上面的注釋就好了,就不重復(fù)說(shuō)明了弥鹦,說(shuō)下重點(diǎn)參數(shù)肚逸。
在Header里有幾個(gè)重點(diǎn)參數(shù)爷辙,分別是:pragma-device
、pragma-os
朦促、pragma-uuid
膝晾、pragma-unionid
、pragma-dpid
务冕、M-SHARK-TRACEID
血当、i
。
序號(hào) | 名稱 | 值 | 說(shuō)明 |
---|---|---|---|
1 | pragma-device |
4***3 | 設(shè)備的IMEI |
2 | pragma-os |
MApi 1.4 (com.dianping.v1 10.36.3 om_sd_** NXT-DL00; Android 8.0) | 類似于UA |
3 | pragma-uuid |
11139***2 | 本地生成 |
4 | pragma-unionid |
158bca***83 | 需要提交請(qǐng)求換取 |
5 | pragma-dpid |
158bca***83 | 同pragma-unionid
|
6 | M-SHARK-TRACEID |
11158b***a | 本地算法生成禀忆,需要看源碼 |
7 | i |
11***4 | 請(qǐng)求序號(hào)臊旭,依此遞增 |
四、請(qǐng)求頭Header箩退、URL參數(shù)構(gòu)造
首先構(gòu)造Header里的參數(shù)离熏,pragma-device
是IMEI
,這個(gè)比較容易構(gòu)造戴涝,pragma-os
類似于UserAgent
滋戳,也比較好構(gòu)造,pragma-uuid
是用UUID
算法生成的啥刻,剩下的pragma-unionid
和pragma-dpid
其實(shí)可以一致奸鸯,或者pragma-dpid
可以留空,那最關(guān)鍵的就是獲取到pragma-unionid
和M-SHARK-TRACEID
了郑什,那如何構(gòu)造pragma-unionid
和M-SHARK-TRACEID
呢府喳?這就需要看源碼了,經(jīng)過(guò)一番定位查找蘑拯,結(jié)果如下钝满。
unionid參數(shù)生成,JAVA源碼:
// 源程序獲取unionid函數(shù)申窘,包路徑:com.meituan.android.common.unionid.oneid.OneIdHelper
private static void getOneIdByNetwork(final DeviceInfo deviceInfo, final OneIdNetworkHandler oneIdNetworkHandler, final List<IOneIdCallback> list, String str, final String str2) {
Object[] objArr = {deviceInfo, oneIdNetworkHandler, list, str, str2};
ChangeQuickRedirect changeQuickRedirect2 = changeQuickRedirect;
if (PatchProxy.isSupport(objArr, null, changeQuickRedirect2, true, "24218b6da0953568d0f2ba37b8d38f2d", RobustBitConfig.DEFAULT_VALUE)) {
PatchProxy.accessDispatch(objArr, null, changeQuickRedirect2, true, "24218b6da0953568d0f2ba37b8d38f2d");
} else if (deviceInfo == null || oneIdNetworkHandler == null) {
Log.e(TAG, "getoneIdByNetwork: one of the parameters is null");
} else {
_oneid_request(deviceInfo, oneIdNetworkHandler, list, str, str2, 1);
try {
MonitorManager.addEvent(deviceInfo.stat, "oaid", 0, true);
OaidManager.getInstance().getOaid(sContext, new OaidCallback2() {
/* class com.meituan.android.common.unionid.oneid.OneIdHelper.AnonymousClass1 */
public static ChangeQuickRedirect changeQuickRedirect;
@Override // com.meituan.android.common.unionid.oneid.oaid.OaidCallback
public void onSuccuss(boolean z, String str, boolean z2) {
}
...
public static void _oneid_request(DeviceInfo deviceInfo, OneIdNetworkHandler oneIdNetworkHandler, List<IOneIdCallback> list, String str, String str2, int i) {
String request = OneIdNetworkHandler.request(sContext, str, deviceInfo, str2, i);
if (!TextUtils.isEmpty(request)) {
if (!TextUtils.isEmpty(lastOneid) && !lastOneid.equals(request)) {
JSONObject jSONObject = new JSONObject();
try {
jSONObject.put("req", deviceInfo.toString());
jSONObject.put("url", str);
jSONObject.put("new", request);
jSONObject.put("old", lastOneid);
LogMonitor.watch(LogMonitor.ONEID_CHANGE_TAG, "", jSONObject);
} catch (Exception e) {
c.a(e);
e.printStackTrace();
}
}
...
unionid 參數(shù)生成弯蚜,python 版本:
import time
import random
import uuid
import json
import requests
class UnionidHelper:
'''
UnionidHelper 是用來(lái)生成獲取unionid所需參數(shù)的
[注意]初始化一次只能獲取一次里面的參數(shù),否則參數(shù)將是重復(fù)的剃法,無(wú)法生成新的unionid
'''
def __init__(self, device_info=None, brand=None,model=None,app_source=None,app_version=None,imei1=None,imei2=None,
androidId=None,osName=None,os_version=None,serialNumber=None,bluetoothMac=None,wifiMac=None):
'''
device_info 字典中含有其他參數(shù)時(shí)碎捺,則其他參數(shù)可以不用傳入
'''
self.brand = brand if 'brand' not in device_info else device_info['brand']
self.model = model if 'model' not in device_info else device_info['model']
self.app_source = app_source if 'app_source' not in device_info else device_info['app_source'] # 應(yīng)用來(lái)源(不帶前綴om_sd_)
self.app_version = app_version if 'app_version' not in device_info else device_info['app_version']
self.imei1 = imei1 if 'imei1' not in device_info else device_info['imei1']
self.imei2 = imei2 if 'imei2' not in device_info else device_info['imei2']
self.androidId = androidId if 'androidId' not in device_info else device_info['androidId'] # len = 16
self.osName = osName if 'osName' not in device_info else device_info['osName'] # 手機(jī)中的版本號(hào)
self.os_version = os_version if 'os_version' not in device_info else device_info['os_version'] # android version
self.serialNumber = serialNumber if 'serialNumber' not in device_info else device_info['serialNumber']
self.bluetoothMac = bluetoothMac if 'bluetoothMac' not in device_info else device_info['bluetoothMac']
self.wifiMac = wifiMac if 'wifiMac' not in device_info else device_info['wifiMac']
self.localid = LocalId()
self.url_unionid_register = 'http://api-unionid.meituan.com/unionid/android/register'
self.header = {
"Accept-Charset": "UTF-8",
"uuidRequestId": self.localid.gen_localId(),
"uuidSessionId": self.localid.gen_localId(),
"retrofit_exec_time": str(int(time.time()*1000)),
"Accept-Encoding": "gzip",
"Content-Type": "application/json;charset=UTF-8",
"Content-Length": '0',
"User-Agent": f"Dalvik/2.1.0 (Linux; U; Android {os_version}; {self.model} Build/{osName})", # 待修改
"Host": "api-unionid.meituan.com",
"Connection": "Keep-Alive"
}
self.logInfo = {
"processName": "com.dianping.v1",
"events": [
{
"markKey": "buCallStart",
"markValue": 121,
"incrementalId": 0,
"opName": 0,
"threadName": "Aurora#2",
"timestamp": int(time.time()*1000),
"uptimeMillis": 37244436+random.randint(-5,10)
},
{
"markKey": "dpid",
"markValue": 130,
"incrementalId": 1,
"opName": 0,
"threadName": "Aurora#2",
"timestamp": int(time.time()*1000),
"uptimeMillis": 37245536+random.randint(-5,10)
},
...
],
"rtt": {
"sessionId": ""
}
}
self.data = {
"appInfo": {
"app": "com.dianping.v1",
"version": self.app_source,
"appName": "dianping_nova",
"sdkVersion": "1.16.11",
"userId": "",
"downloadSource": f"om_sd_{self.app_source}" # 待修改
},
"idInfo": {
"localId": self.localid.gen_localId(),
"unionId": "",
"uuid": "",
"dpid": "",
"requiredId": str(random.randint(1,2)) # 1:registerOrUpdate, 2:startDpid, 4:registerOrUpdateUuid
},
"logInfo": json.dumps(self.logInfo),
"environmentInfo": {
"platform": "android",
"osName": self.osName, # 待修改
"osVersion": self.os_version,
"clientType": "6"
},
"deviceInfo": {
"keyDeviceInfo": {
"imei1": self.imei1,
"imei2": self.imei2,
"meid": "",
"androidId": self.androidId, # 6db88d1534ef1089
"oaid": "",
"appid": {
"share": "", # raw: AndroidMuMu$6db88d1534ef1089
"local": {
"oldid": f"{self.brand}{self.model}${androidId}", # raw: AndroidMuMu$6db88d1534ef1089
"newid": ""
}
}
},
"secondaryDeviceInfo": {
"serialNumber": self.serialNumber, # ZX1G42CPJD
"bluetoothMac": self.bluetoothMac.lower(),
"wifiMac": self.wifiMac.lower(),
"simulateId": "",
"uuid": uuid.uuid4().__str__()
},
"brandInfo": {
"brand": self.brand,
"deviceModel": self.model
}
},
"communicationInfo": {
"jntj": "",
"jddje": "",
"nop": "unknown"
},
"mark": json.dumps({
"dpid": 9,
"unionId": 9,
"appid_share": 131,
"appid_local": 130
})
}
if __name__ == '__main__':
unionid_helper = UnionidHelper()
session = requests.session()
session.headers.clear()
session.headers.update(unionid_helper.header)
res = session.post(url=unionid_helper.url_unionid_register, data=json.dumps(unionid_helper.data))
res_data = json.loads(res.text)
code = res_data['code']
if code == 0:
print(res_data['data']['unionId'])
print(res.content)
M-SHARK-TRACEID參數(shù)生成算法, python版本:
def gen_M_SHARK_TRACEID(pragma_unionid):
'''
M-SHARK-TRACEID : 113888...6253a7a3161510...360d09cda
結(jié)構(gòu):11 388...62 53a7a3 161510...360 d09cda
[fix] [unionid] [uuid(pre-6bit)] [timestamp(ms)] [uuid(pre-6bit)]
'''
prefix = '11'
uuid_pre = uuid.uuid4().__str__()[:6]
uuid_end = uuid.uuid4().__str__()[:6]
timestamp = str(int(time.time()*1000))
return prefix + pragma_unionid + uuid_pre + timestamp + uuid_end
localId 生成算法(unionid 獲取時(shí)需要這個(gè)參數(shù))贷洲,JAVA源碼:
// 包路徑:com.meituan.android.common.unionid.oneid.util.TempIDGenerator
public class TempIDGenerator {
public static String generate() {
SecureRandom secureRandom = new SecureRandom();
byte[] bArr = new byte[50];
byte[] bArr2 = new byte[24];
byte[] bArr3 = new byte[24];
secureRandom.nextBytes(bArr2);
secureRandom.nextBytes(bArr3);
for (int i = 0; i < bArr2.length; i++) {
bArr2[i] = (byte) (bArr2[i] & 15);
bArr3[i] = (byte) (bArr3[i] & 15);
}
System.arraycopy(bArr2, 0, bArr, 0, bArr2.length);
System.arraycopy(bArr3, 0, bArr, 26, bArr3.length);
handleBytes(bArr2);
handleBytes(bArr3);
byte checker = getChecker(bArr2);
byte checker2 = getChecker(bArr3);
bArr[24] = checker;
bArr[25] = checker2;
return byteArrayToHexString(bArr);
}
private static void handleBytes(byte[] bArr) {
for (int i = 0; i < bArr.length; i += 2) {
bArr[i] = (byte) (bArr[i] * 2);
while (bArr[i] >= 10) {
bArr[i] = (byte) ((bArr[i] % 10) + ((bArr[i] / 10) % 10));
}
}
}
private static byte getChecker(byte[] bArr) {
int i = 0;
for (byte b : bArr) {
i += b;
}
byte b2 = (byte) (10 - ((byte) (i % 10)));
if (b2 == 10) {
return 0;
}
return b2;
}
private static String byteArrayToHexString(byte[] bArr) {
StringBuffer stringBuffer = new StringBuffer(bArr.length);
for (byte b : bArr) {
stringBuffer.append(Integer.toHexString(b));
}
return new String(stringBuffer);
}
}
localId生成算法(unionid 獲取時(shí)需要這個(gè)參數(shù))收厨,python版本:
import time
import random
import uuid
import json
import requests
class LocalId:
'''
大眾點(diǎn)評(píng)localId生成算法(對(duì)應(yīng):TempIDGenerator.generate())
'''
def __init__(self):
self.bArr2 = None # [random.randint(0,15) for i in range(24)]
self.bArr3 = None # [random.randint(0,15) for i in range(24)]
self.bArr = None # self.bArr2 + [0,0]+ self.bArr3
def handleBytes(self, bArr):
'''
func:將偶數(shù)位的數(shù)先乘2再變成小于10的數(shù)
'''
for i, v in enumerate(bArr):
if i % 2 != 0:
continue
bArr[i] *= 2
while bArr[i] >= 10:
bArr[i] = bArr[i] % 10 + bArr[i] // 10 % 10
return bArr
def getChecker(self, bArr):
_sum = sum(bArr)
b2 = 10 - _sum % 10
return b2 if b2 != 10 else 0
def gen_localId(self):
self.bArr2 = [random.randint(0,15) for i in range(24)]
self.bArr3 = [random.randint(0,15) for i in range(24)]
self.bArr = self.bArr2 + [0,0]+ self.bArr3
self.bArr2 = self.handleBytes(self.bArr2)
self.bArr3 = self.handleBytes(self.bArr3)
check2 = self.getChecker(self.bArr2)
check3 = self.getChecker(self.bArr3)
self.bArr[24] = check2
self.bArr[25] = check3
bArr_str = ''.join('{:1x}'.format(x) for x in self.bArr)
return bArr_str
cx 生成算法,JAVA源碼:
省略了...
cx 生成算法优构,python版本:
由于需要用到的參數(shù)過(guò)多诵叁,這里就不貼出來(lái)了了,太影響閱讀體驗(yàn)了钦椭,就給個(gè)大致思路吧:
加密過(guò)程: 待加密字符串(str) -> 編碼(byte) -> des加密(byte) -> base64編碼(byte) -> url編碼(str) -> 密文(str)
解密過(guò)程: 密文(str) -> url解碼(str) -> base64解碼(byte) -> des解密(byte) -> 解碼成字符串(str)
生成cx時(shí)用到的DES加解密算法:
from pyDes import des, CBC, PAD_PKCS5
import base64
import urllib.parse
import json
import uuid
import time
class DES_Encrypt:
def __init__(self):
# 秘鑰
self.KEY = 'k***'
def des_encrypt_byte(self, content):
"""
DES 加密
:param s: 原始字符串
:return: 加密后字符串拧额,byte
"""
secret_key = self.KEY # 密碼
iv = secret_key # 偏移
# secret_key:加密密鑰碑诉,CBC:加密模式,iv:偏移, padmode:填充
des_obj = des(secret_key, CBC, iv, pad=None, padmode=PAD_PKCS5)
# 返回為字節(jié)
secret_bytes = des_obj.encrypt(content, padmode=PAD_PKCS5)
# 返回為16進(jìn)制
# return binascii.b2a_hex(secret_bytes)
return secret_bytes
def des_descrypt_byte(self, content):
"""
DES 解密
:param s: 加密后的字符串侥锦,16進(jìn)制
:return: 解密后的字符串
"""
secret_key = self.KEY
iv = secret_key
des_obj = des(secret_key, CBC, iv, pad=None, padmode=PAD_PKCS5)
decrypt_str = des_obj.decrypt(content, padmode=PAD_PKCS5)
return decrypt_str
到這里进栽,發(fā)起請(qǐng)求的全部重點(diǎn)參數(shù)其含義以及如何生成的就都清楚了,其中的重點(diǎn)參數(shù):
pragma-unionid
恭垦、pragma-dpid
快毛、M-SHARK-TRACEID
、cx
署照、localId
的生成算法都給出來(lái)了祸泪,現(xiàn)在就可以去發(fā)起請(qǐng)求了。
五建芙、response響應(yīng)解析
按照以往的WEB爬蟲(chóng)來(lái)說(shuō)没隘,能夠成功發(fā)起請(qǐng)求,爬蟲(chóng)基本上就算完成了禁荸,但是對(duì)于APP爬蟲(chóng)右蒲,尤其是這種做了大量加密的APP來(lái)說(shuō),其返回的response必然也會(huì)進(jìn)行相應(yīng)的加密赶熟,這里的解密也參考了另一位github大佬的代碼瑰妄,點(diǎn)評(píng)APP把解密的算法放到了so庫(kù)里面,所以這時(shí)候借助JADX-gui就行不通了映砖,我們先來(lái)看下JADX-gui中解出來(lái)的解密算法:
// 包路徑:com.dianping.util.NativeHelper
public class NativeHelper {
public static final boolean a;
private static native boolean a();
public static native boolean nd(byte[] bArr, byte[] bArr2, byte[] bArr3, byte[] bArr4);
public static native byte[] ndug(byte[] bArr, byte[] bArr2, byte[] bArr3);
public static native boolean ne(byte[] bArr, byte[] bArr2, byte[] bArr3, byte[] bArr4);
public static native byte[] nug(byte[] bArr);
static {
boolean z;
b.a("1af3893fc7dcb0905311776314368f4d");
try {
if (!aa.a("nh", NativeHelper.class)) {
System.loadLibrary(b.b("nh"));
}
z = a();
} catch (Throwable th) {
c.a(th);
ab.c("failed to load native helper");
z = false;
}
a = z;
}
}
里面沒(méi)有算法的具體實(shí)現(xiàn)间坐,說(shuō)明其具體實(shí)現(xiàn)在so層,這時(shí)候就要用IDA來(lái)查看其使用的是什么算法了邑退。
在APP解壓出來(lái)的文件中竹宋,找到libnh.so
這個(gè)文件(從System.loadLibrary(b.b("nh"))
這里可以知道),查看其導(dǎo)出函數(shù)地技,再一頓操作轉(zhuǎn)成可讀性稍好的C代碼蜈七,這里明顯能從函數(shù)名看出來(lái)使用了AES/CBC加密,
關(guān)于AES加解密莫矗,直接調(diào)庫(kù)就好了飒硅;關(guān)于AES的秘鑰和偏移,可以從JADX-gui反編譯后的文件中找到作谚,這里的思路是根據(jù)加解密函數(shù)去查找三娩,看哪里調(diào)用了這個(gè)函數(shù),傳入的參數(shù)是哪里來(lái)的妹懒,就能找到了尽棕。
到這里,很大一部分工作就完成了彬伦,如果實(shí)際測(cè)試一下可以發(fā)現(xiàn)滔悉,解密后的數(shù)據(jù)仍然不是我們需要的數(shù)據(jù),還需要進(jìn)一步進(jìn)行處理单绑,如果源碼追蹤是仔細(xì)一點(diǎn)回官,可以看到這一步就是做了一個(gè)變量名的映射,然而重新建立這個(gè)映射關(guān)系還是需要花點(diǎn)時(shí)間的搂橙,源程序的部分映射關(guān)系如下:
//包路徑:com.dianping.model.AdLog
...
@SerializedName("feedback")
public String a;
@SerializedName("impUrl")
public String b;
@SerializedName("clickUrl")
public String c;
@SerializedName("thirdpartyMonitorImpUrls")
public String[] d;
@SerializedName("thirdpartyMonitorClickUrls")
public String[] e;
@SerializedName("ext")
public String f;
...
public void writeToParcel(Parcel parcel, int i) {
parcel.writeInt(2633);
parcel.writeInt(this.isPresent ? 1 : 0);
parcel.writeInt(35360);
parcel.writeString(this.f);
parcel.writeInt(31004);
parcel.writeStringArray(this.e);
parcel.writeInt(53501);
parcel.writeStringArray(this.d);
parcel.writeInt(3264);
parcel.writeString(this.c);
parcel.writeInt(43874);
parcel.writeString(this.b);
parcel.writeInt(7952);
parcel.writeString(this.a);
parcel.writeInt(-1);
}
重建映射關(guān)系后歉提,就能解析出完成的JSON數(shù)據(jù)了,下面給出一個(gè)簡(jiǎn)化版本区转。
注:
下面
response
解碼部分本來(lái)是不打算放出來(lái)的苔巨,一是原創(chuàng)不是我,二是影響閱讀體驗(yàn)废离,但問(wèn)的同學(xué)是在太多了侄泽,所以考慮下,還是放出來(lái)了蜻韭。
def decrypt_aes(key, iv, content):
generator = AES.new(key=key, mode=AES.MODE_CBC, iv=iv)
decrypt = generator.decrypt(content)
return decrypt
def decode_aes(content, model=None):
aes_data = decrypt_aes(key=key, iv=iv, content=content)
ungzip_data = gzip.decompress(aes_data)
return ungzip_data
# 請(qǐng)求數(shù)據(jù)并做AES解密悼尾,再做變量名的重映射
res = session.get(url_user_comment, proxies=None, timeout=20)
body = decode_aes(res.content)
if res.status_code == 200:
res_data = json.dumps(decode_model(body), indent=4)
res_data = json.loads(res_data)
像下面這種model
文件是有相互依賴關(guān)系的,有時(shí)解析某個(gè)model
內(nèi)的數(shù)據(jù)時(shí)會(huì)依賴其他model
的數(shù)據(jù)肖方,這里最好就全部解析一遍闺魏。如果需要爬取的內(nèi)容較多,也需要編寫(xiě)多個(gè)類似于下面的model
文件俯画,可以用程序來(lái)處理析桥,將java
的model
轉(zhuǎn)為python
的model
。
# encoding: utf-8
from model import BaseModel, add_model
@add_model(0x9bc3)
class AdLog(BaseModel):
field_map = {'a': 'feedback', 'b': 'impUrl', 'c': 'clickUrl', 'd': 'thirdpartyMonitorImpUrls', 'e': 'thirdpartyMonitorClickUrls', 'f': 'ext'}
def j_flag_2633(self):
"""
0xa49 -> :sswitch_0
:return:
"""
self.result[self.field_map.get('isPresent', 'isPresent')] = self.archive_d_b()
def j_flag_35360(self):
"""
0x8a20 -> :sswitch_1
:return:
"""
self.result[self.field_map.get('f', 'f')] = self.archive_d_g()
def j_flag_31004(self):
"""
0x791c -> :sswitch_2
:return:
"""
self.result[self.field_map.get('e', 'e')] = self.archive_d_n()
def j_flag_53501(self):
"""
0xd0fd -> :sswitch_3
:return:
"""
self.result[self.field_map.get('d', 'd')] = self.archive_d_n()
def j_flag_3264(self):
"""
0xcc0 -> :sswitch_4
:return:
"""
self.result[self.field_map.get('c', 'c')] = self.archive_d_g()
def j_flag_43874(self):
"""
0xab62 -> :sswitch_5
:return:
"""
self.result[self.field_map.get('b', 'b')] = self.archive_d_g()
def j_flag_7952(self):
"""
0x1f10 -> :sswitch_6
:return:
"""
self.result[self.field_map.get('a', 'a')] = self.archive_d_g()
下面的model解碼程序是github大佬的艰垂,本來(lái)只能兼容比較舊的版本泡仗,我修改后能兼容到我測(cè)試所用的版本,之后的版本沒(méi)有繼續(xù)測(cè)試了材泄,沒(méi)有大版本更新的話是可以不作修改繼續(xù)使用的沮焕,具體情況請(qǐng)自行測(cè)試或回退幾個(gè)版本測(cè)試。
# encoding: utf-8
import struct
import os
from io import BytesIO
import logging
import time
flag_model_map = {}
class BaseModel:
field_map = {}
def __init__(self, data):
self.result = {}
if isinstance(data, BytesIO):
self.data = data
self.raw_data = data
else:
self.raw_data = data
self.data = BytesIO(data)
def unpack(self, fmt, stream):
size = struct.calcsize(fmt)
buf = stream.read(size)
try:
return struct.unpack(fmt, buf)
except struct.error as e:
logging.error("數(shù)據(jù)不全:{}".format(buf))
def main(self):
self.result = self.archive_d_a_archive_c()
return self.result
def decode(self):
while True:
j_flag = self.archive_d_j()
if j_flag < 1:
break
j_flag_func_name = 'j_flag_{}'.format(j_flag)
if hasattr(self, j_flag_func_name):
getattr(self, j_flag_func_name)()
else:
try:
self.archive_d_i()
except ValueError as e:
logging.error("數(shù)據(jù)錯(cuò)誤不解析了 j_flag:{} 當(dāng)前model:{}".format(j_flag, self.__class__))
raise e
def archive_d_b(self):
flag, = self.unpack(">b", self.data)
if flag == 0x54:
data = 0x1
elif flag in [0x46, 0x4e]:
data = 0x0
else:
logging.error("archive_d_b拋錯(cuò):unable to read boolean")
raise ValueError()
logging.info("找到bool:{}".format(data))
return data
def archive_d_c(self):
flag, = self.unpack(">b", self.data)
if flag == 0x49:
data, = self.unpack(">i", self.data)
elif flag == 0x4e:
data = 0x00
else:
logging.error("archive_d_c拋錯(cuò)")
raise ValueError()
logging.info("找到int:{}".format(data))
return data
def archive_d_d(self):
flag, = self.unpack(">b", self.data)
if flag == 0x4c:
data, = self.unpack(">q", self.data)
elif flag == 0x4e:
data = 0x0
else:
logging.error("archive_d_d拋錯(cuò)")
raise ValueError()
logging.info("archive_d_d找到string:{}".format(data))
return data
def archive_d_j(self):
flag, = self.unpack(">b", self.data)
if flag == 0x4d:
data, = self.unpack(">h", self.data)
data &= 0xffff
elif flag == 0x5a:
data = 0x00
else:
logging.error("archive_d_j拋錯(cuò)")
raise ValueError()
logging.info("當(dāng)前model{}".format(self.__class__))
return data
def archive_d_e(self):
flag, = self.unpack(">b", self.data)
if flag == 0x44:
data, = self.unpack(">d", self.data)
elif flag == 0x4e:
data = 0x0
else:
logging.error("archive_d_e拋錯(cuò)")
raise ValueError()
logging.info("當(dāng)前model{}".format(self.__class__))
return data
def archive_d_g(self):
flag, = self.unpack(">b", self.data)
if flag == 0x53:
length, = self.unpack(">h", self.data)
length = 0xffff & length
data, = self.unpack(">{}s".format(length), self.data)
elif flag == 0x42:
length, = self.unpack(">i", self.data)
# length = 0xffff & length
data, = self.unpack(">{}s".format(length), self.data)
elif flag == 0x4e:
data = b""
else:
logging.error("archive_d_g拋錯(cuò)")
raise ValueError()
logging.info("找到string:{}".format(data.decode()))
return data.decode()
def archive_d_n(self):
data = []
flag, = self.unpack(">b", self.data)
if flag == 0x4e:
data = [""]
elif flag == 0x41:
length, = self.unpack(">h", self.data)
length = 0xffff & length
for i in range(length):
data.append(self.archive_d_g())
else:
logging.error("archive_d_n拋錯(cuò)")
raise ValueError()
logging.info("找到string:{}".format(data))
return data
def archive_d_i(self):
flag, = self.unpack(">b", self.data)
if flag == 0x41:
length, = self.unpack(">h", self.data)
length = length & 0xffff
for i in range(length):
self.archive_d_i()
elif flag == 0x44:
self.unpack(">d", self.data)
elif flag == 0x49:
self.unpack(">i", self.data)
elif flag == 0x4c:
self.unpack(">q", self.data)
elif flag == 0x4f:
self.unpack(">h", self.data)
while self.archive_d_j() > 0:
self.archive_d_i()
elif flag == 0x53:
position, = self.unpack(">h", self.data)
position = (position & 0xffff) + self.data.tell()
self.data.seek(position)
elif flag == 0x55:
self.unpack(">i", self.data)
elif flag in [0x46, 0x4e, 0x54, ]:
pass
elif flag in [0x42, 0x43, 0x45, 0x47, 0x48, 0x4a, 0x4b, 0x4d, 0x50, 0x51, 0x52]:
raise ValueError("unable to skip object:")
def archive_d_a_archive_c(self):
flag, = self.unpack(">b", self.data)
if flag == 0x4e:
logging.info("創(chuàng)建了一個(gè)空對(duì)象")
return {}
elif flag == 0x4f:
data, = self.unpack(">h", self.data)
data &= 0xffff
model_class = flag_model_map.get(data, None)
if model_class:
model_instance = model_class(self.data)
model_instance.decode()
return model_instance.result
else:
logging.error("archive_d_a_archive_c未找到此model:{}".format(hex(data)))
# raise ValueError()
# return f'"archive_d_a_archive_c未找到此model:{format(hex(data))}"'
print(f'"archive_d_a_archive_c未找到此model:{format(hex(data))}"')
else:
logging.error("archive_d_a_archive_c拋錯(cuò)")
raise ValueError()
def archive_d_b_archive_c(self):
result = []
flag, = self.unpack(">b", self.data)
if flag == 0x4e:
logging.info("創(chuàng)建空對(duì)象")
return []
elif flag == 0x41:
length, = self.unpack(">h", self.data)
length = 0xffff & length
for i in range(length):
data = self.archive_d_a_archive_c()
logging.info("創(chuàng)建了一個(gè)對(duì)象:{}".format(data))
result.append(data)
return result
else:
logging.error("拋錯(cuò)")
raise ValueError()
def add_model(flag):
def wrapper(cls):
if flag_model_map.get(flag):
# raise ValueError("model已存在:{}".format(flag))
print("model已存在:{}".format(flag))
return cls
flag_model_map[flag] = cls
return cls
return wrapper
def decode_model(data):
"""
:param data:
:return: dict
"""
if not isinstance(data, bytes):
data = bytes.fromhex(data)
logging.debug("需要解密的body:{}".format(data.hex()))
basemodel = BaseModel(data)
basemodel.main()
result = basemodel.result
return result
def import_all_model():
all_list = os.listdir(os.path.dirname(__file__))
for i in all_list:
if "__" not in i and ".py" in i:
__import__("model." + i.replace(".py", ""))
t1 = time.time()
print('正在加載model...')
import_all_model()
print(f'[{round(time.time()-t1, 2)}s] model加載完成!')
六拉宗、優(yōu)缺點(diǎn)分析
序號(hào) | 優(yōu)點(diǎn) | 缺點(diǎn) |
---|---|---|
1 | 程序運(yùn)行更快 | 參數(shù)構(gòu)造麻煩 |
2 | - | response解析麻煩 |
3 | - | 需要對(duì)APP進(jìn)行逆向 |
4 | - | 需要理清APP的各個(gè)模塊間的邏輯關(guān)系 |
七峦树、結(jié)語(yǔ)
這是APP爬蟲(chóng),對(duì)于新手來(lái)說(shuō)還是難度很大的旦事,對(duì)于有逆向APP經(jīng)驗(yàn)的同學(xué)來(lái)說(shuō)魁巩,這里復(fù)雜的可能就是理清邏輯關(guān)系了,文中給出了關(guān)鍵的思路和代碼姐浮,有了這些思路谷遂,相信對(duì)于想入門高階爬蟲(chóng)的同學(xué)來(lái)說(shuō),多少還是有點(diǎn)幫助的卖鲤。但是在此再次聲明肾扰,雖然APP爬蟲(chóng)可以大規(guī)模爬取數(shù)據(jù)畴嘶,但是最好還是不要給對(duì)方服務(wù)器造成壓力,影響其服務(wù)的正常運(yùn)行集晚,做任何爬蟲(chóng)都是這樣的窗悯,主要還是以學(xué)習(xí)爬蟲(chóng)思想為主,了解爬蟲(chóng)的常見(jiàn)對(duì)抗升級(jí)方式偷拔。
注:
- 如果您不希望我在文章提及您文章的鏈接蒋院,或是對(duì)您的服務(wù)器造成了損害,請(qǐng)聯(lián)系我對(duì)文章進(jìn)行修改莲绰;
- 本文僅爬取公開(kāi)數(shù)據(jù)欺旧,不涉及到用戶隱私;