引言
俗話說的好"工欲善其事,必先利其器",在日常工作中,如果擁有一款或N款好用的工具,那么工作效率將會成幾何倍提升。這篇文章與其說是寫一個去廣告的工具,不如說是寫一個自動化工具更為準確。我不會講代碼的細節,“一千個人眼里有一千個哈姆雷特”,每個人寫代碼的風格都不一樣,最重要只有思路(實際上這個思路也并不高明,唯一的重點就是清楚原理),你們可以用喜歡且擅長的語言及方式來進行實現,不過最終我會放出自己的源代碼(我的代碼相對于單一目標的實現可能會有些繁雜,只需要一兩百行的代碼我寫了兩千行還不到頭哈哈,所以在文中只會貼上需要的部分,想要閱讀完整代碼的可以上我的github,當然在這之前請記住"文明社會"這四個字)。
那么開始步入正題,我們要開發的是一款自動化去廣告的工具,何為自動化,自動化就是解放雙手,讓程序完成需要你動手的一系列操作。那么,想要自動化就必須先知道正常手工是如何操作的,接下來,我們來探討一下APK如何去廣告這件事情。
本文所敘都是在APK沒有加殼/加密或者已經完美脫殼/解密的情況下
如何添加廣告
兵家云:“知己知彼,百戰不殆”,假如你知道這個程序是如何被添加上廣告的,那么你的后續操作將會輕松很多,因為你不必再花費大量的時間對廣告SDK進行分析。我們先了解一下廣告是怎樣以一種形式存在,以Google的廣告為例,Google的廣告使用范圍很廣,在Google Play上無論是應用還是游戲,有很大部分都是使用其提供的廣告組件。 在Google提供的Android集成開發環境Android Studio上,對著Project點擊右鍵Open Module Setting然后可以看到這么一個東西

這是什么呢?這是Google提供的廣告SDK,勾選后他將會自動下載開發工具包并將其集成到你的Project上,沒錯,廣告就是從這么一個SDK里來的,它就是我們的敵人!我們到他的官方網站可以看到接入指南(https://developers.google.com/admob/android/quick-start),可以看到加載廣告的第一步就是初始化SDK
package ... import ... import com.google.android.gms.ads.MobileAds; public class MainActivity extends AppCompatActivity { ... protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); // Sample AdMob app ID: ca-app-pub-3940256099942544~3347511713 MobileAds.initialize(this, "YOUR_ADMOB_APP_ID"); } ... }
初始化的參數有一個ADMOB_APP_ID,這是開發者的憑證,填上這個ID才可以拿到屬于你的那份廣告收益。在頁面的下半部分還可以看到其廣告的幾種類型,其實我猜市面上的大部分廣告組件都是類似的:
- Banner:橫幅廣告,這種無論是在桌面端還是移動端都非常常見,它占用你屏幕的一小部分來顯示一個橫幅的廣告視圖,但是大多數情況下并不能關閉它;
- Interstitial:懸浮窗廣告,這個在Html和Android上較為常見,它占用屏幕的面積并不固定,有可能是占用一半屏幕甚至是整個屏幕,不過用戶卻可以手動將他關閉(不能關閉的那叫流氓)。
- Rewarded Video:其實就是視頻廣告,占用全屏,而且你還得等他全部播放完才能關閉他,當然也有些只需觀看一定時間即可。
Native暫時不做考慮,這是谷歌一種比較高級的廣告形式(好像也并沒有廣泛使用?)。 想要接入這些廣告也十分簡單,比如Banner,你只要在布局文件上添加一個AdView然后像這樣加載它即可
package ... import ... import com.google.android.gms.ads.AdRequest; import com.google.android.gms.ads.AdView; public class MainActivity extends AppCompatActivity { private AdView mAdView; protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); MobileAds.initialize(getApplicationContext(), "ca-app-pub-3940256099942544~3347511713"); mAdView = (AdView) findViewById(R.id.adView); AdRequest adRequest = new AdRequest.Builder().build(); mAdView.loadAd(adRequest); } ... }
而Interstitial甚至都不需要添加View,只需要loadAd然后在需要的時候調用show()方法將他顯示出來即可。 好了,就說這些,不然我都要以為我是Google的頂級廣告形式 - 人工廣告了,接下來談談去廣告的方法。
傳統的綠化方式
此處僅從APK本身入手,不討論如Hook,Hosts等手段。
從代碼的層面上,我們知道了廣告如何添加,那么想要將其移除相信對大家也不是什么難事,一般去廣告的流程大致是這樣的:
反編譯APK --> 移除相關代碼 --> 重打包測試
對于移除相關代碼,有多種實現方式,比如Banner,你完全可以將其visibility屬性設置為GONE就能把他隱藏掉(雖然我沒測試過是否有效,哈哈)。不過我更加偏向于刪除其加載的入口調用,可以來實戰演示一下,下面以ADM(Advanced Download Manager)為例,相信很多人都知道這個軟件吧,Android上的下載神器。沒去廣告之前他是這樣子的:

可以看到底部的橫幅圖片,這就是Banner廣告。在上一節中我們知道它調用了AdView的loadAd方法來加載廣告,那么我們只要找到這個方法的調用點,然后將其刪除就可以讓廣告無法順利加載出來。那么怎么做呢?按照國際慣例,首先是反編譯APK,我這里使用Android killer這個工具來進行反編譯,然后你會得到一些smali文件和資源文件。關于逆向的一些基本知識我這里不在闡述,對逆向有興趣的同學可以自己搜索資料學習。我們在Android Killer中搜索"Lcom/google/android/gms/ads/AdView;->loadAd",然后會出現這么一些結果:

這里我只選擇對Main.smali中的代碼進行處理,至于為什么,請參考上上句話,當然,就算你將它們全部處理了也不會有什么影響。我對搜索出來的這兩行代碼整行刪除,然后保存編譯。可以看到Banner廣告已經不會再加載了:

是不是感覺很簡單?其實本來就沒有什么難度,甚至比添加廣告還要簡單,對于Interstitial或者Rewarded Video也是一樣,可以發現,他們都調用了一個叫做loadAd的方法,所以我們可以進行模糊搜索,例如搜索";->loadAd(",然后會出現較多的結果,可以針對性的進行處理,不過我想就算是全部處理也不會有多大的影響。 現在你已經知道了綠化廣告的原理,在進行了多次的重復工作之后,你會發現,就算這是最簡單快捷的方法,但是效率依然很低,并且工作都是重復的,因為大部分廣告都是出于同一個SDK。那么,可以開始考慮讓萬能的程序幫你解決問題了!
自動化綠化方法
大佬的操作
編寫一個簡單的自動化處理工具并不難,只要清楚了工作原理并且有一點點編程的能力,就可以寫出一個幫助你快速處理任務的程序。按照國際慣例,無論是手動還是自動,第一步都是先反編譯,這里我們可以直接調用apktool或者baksmali來處理,關于工具的使用及調用的方法有興趣可以自己研究,這并不是我要講的內容。得到反編譯的代碼之后,按照國際慣例第二步,就是找到smali代碼中調用loadAd的地方將其刪除,實現的過程大致如下:
1. 遍歷所有Smali文件讀入 2. 遍歷每一行代碼是否形如 invoke-xxxxx {v*} Lcom/google/android/gms/ads/xxxx;->loadAd 之類的調用代碼 3. 將識別到的代碼行刪除 4. 重新寫出Smali文件
最后就是國際慣例最后一步,重打包,同樣可以利用Apktool或者Smali.jar將其回編譯為APK或者Dex,然后進行簽名、測試即可。這樣一來效率就可以提高很多了,你只要等待若干秒的時間就可以實現去廣告的目的。當然這種方法是有弊端的,如果遇到無法反編譯或者回編譯的情況,那么估計就要花費一般功夫了,并且對于一個追求極致的人來說,這種方法還不夠快!具體代碼我就不寫了,因為我之前寫過Smali相關的處理庫(在我的github上的某個Repository中可以看到,雖然比較簡陋,但是足以應付一些簡單的需求),所以我對這個也沒有多大的興趣,我想做的是一種更加極致的操作。
騷操作
眾所周知,Android程序大部分的代碼是包含在classes.dex里面的,所謂的Smali代碼也就是從classes.dex中的每一個字節翻譯出來的,那么,實際上我們只要改動classes.dex文件中的1個或者N個字節,就可以完成如上相等的效果。Dex文件的每一個字節都代表著相關的含義,具體參照Google的官方文檔Dex文件格式(https://source.android.com/devices/tech/dalvik/dex-format),雖然這些格式相關的數據并不是我們所關心的內容,但是我們必須依靠它來找到我們需要的關鍵位置--字節碼(bytecode),bytecode是程序運行是真正執行的指令(Dalvik字節碼 https://source.android.com/devices/tech/dalvik/dalvik-bytecode ),dex文件格式就是用來幫助系統定位到這些指令的位置。比如我們上文做提到的invoke-xxxxxx就有一套專屬的字節碼,如果我們找到它的位置,然后把字節碼改成0x00,0x00是代表nop的字節碼,nop就是什么都不干的意思,那么這不就是等同于將這條代碼刪除了嗎? 既然如此,我們來整理一下這個程序的執行流程:
解析Dex文件 -> 遍歷所有的字節碼 -> 匹配所有符合自定義規則的位置 -> 將其全部改為0x00 -> 重建DexHeader -> 簽名、測試
我們可以先研究下如何遍歷所有的字節碼: 首先可以使用010 Editor來很方便的分析Dex格式

呃..焦點選中的那個地方就是一個方法的字節碼..可見想要獲取全部還是得花一點功夫的哈。那么,圖中出現的結構體我們在程序中都必須解析出來。而至于Leb128類型的數據,可以參照我的代碼,我的Leb128類實質是無符號的uleb128類型。
我們再研究一下invoke系列字節碼的格式:
指令格式是這樣子的:invoke-kind {vC, vD, vE, vF, vG}, meth@BBBB 這就是在Smali中看到的格式 而字節碼格式是這樣子的:A|G|op BBBB F|E|D|C 而這個是從Hex文件中看到格式 不過由于dex程序是小端對齊,所以真實的表現形式是這樣的:op|G|A BBBB D|C|F|E(應該沒錯吧?歡迎指正)
ACDEFG都是指示寄存器,可以不管,需要注意的就只有op和BBBB: op是opcode,就是操作碼,例如invoke-virtual的opcode就是0x6E; 而這個BBBB是一個method_id,這個method_id是什么呢?在Dex文件格式中可以看到,Dex的數據中有一個叫做method_ids的列表,這個id就是在表中的索引。而使用這個id呢可以獲得這個method的class_id,proto_id和name_id,class_id可以獲取到所屬的類的信息(class_def_item),proto_id可以獲取到方法的參數及返回類型信息(proto_id_item),最后通過string_ids拼湊出一個完整的名稱。 具體是這樣的:
public String getNameByMethodId(int id) { return getName(method_id_list.get(id)); } public String getNameByProtoId(int id) { return getName(proto_id_list.get(id)); } public String getName(Proto_Id_Item proto) { return getString(proto.shorty_id); } public String getName(Method_Id_Item method) { String className = getNameByTyPEID(method.class_id).replaceAll("/", "\\."); className = className.substring(1, className.length() - 2); return className + "." + getString(method.name_id).replaceAll ("\0","") + "("+ getNameByProtoId(method.proto_id).replaceAll("\0","") + ")"; } public String getString(int id) { return new String(string_data_list.get(id).body); }
那么我們就可以明確了解析任務,解析任務包括class_def_item中所有結構體以及string_ids、string_id_item、string_data_item、proto_ids、proto_id_item、method_ids、method_id_item、type_ids、type_iditem,當然,還有最重要的header。我并不是教大家寫代碼,所以這個還是靠你們自己干啦,可以參考我的DexParser類以及Format包下的各個類。或者直接找個開源的DexParser項目也是可以直接調用的(話說其實我這個就算是^^)。 貼一個獲取全部insns的for:
public ArrayList<encoded_method> getAllEncodedMethod(){ ArrayList<encoded_method> all = new ArrayList<encoded_method>(); for (Class_Def_Item cls : class_def_list) { if (cls.class_data == null) { continue; } String clsName = getName(cls); all.addAll(cls.class_data.direct_methods); all.addAll(cls.class_data.virtual_methods); } return all; } public ArrayList<insns_item> getAllInsnsItem() { ArrayList<insns_item> all = new ArrayList<insns_item>(); for (encoded_method method : getAllEncodedMethod()) { if (method.code != null) { all.addAll(method.code.insns_items); } } return all; } //不要問我怎么就這么簡單,難道你要我貼一大堆封裝的代碼出來嗎..
其實還有一個比較簡單的思路,就是只寫一個Code_Item的結構體,然后取出第一個和最后一個encoded_method_item的code_off。然后將這段范圍解析為一個CodeItem的List。然后不就可以為所欲為了嗎~這樣的代碼量會相較少很多。主要還是靠自己發揮,我說過我并不教寫代碼 ^^
這時候關鍵的兩個東西已經有了:獲取所有字節碼以及從method_id獲取名稱的方法。那么剩下的就簡單了,上面說過invoke指令的格式,知道了invode的opcode后面第二位開始就是一個short的method_id,我們可以從這個id獲取到他的名稱,然后判斷是不是那個加載廣告的入口,如果是的話,直接將從opcode開始的6個字節修改為0x00。 示例代碼:
DexChanger changer = new DexChanger(new File(path)); DexFile dexfile = changer.getDexFile(); String magiclist[] = { "com.google.android.gms.ads.AdView.loadAd", "com.google.android.gms.ads.InterstitialAd.loadAd", "com.google.android.gms.ads.reward.RewardedVideoAd.loadAd", "com.mopub.mobileads.AdViewController.loadAd", "com.mopub.mobileads.MoPubInterstitial$MoPubInterstitialView.loadAd" }; for (insns_item insns : dexfile.getAllInsnsItem()) { if (insns.opcode.toString().startsWith("INVOKE")) { changer.move(insns.getFileOff() + 2); // invoke系列指令格式 A|G|op BBBB F|E|D|C ,所以off + 2是methodId int methodId = changer.nextShort() & 0xFFFF; // 轉為無符號數 if (methodId < 0 || methodId > dexfile.getHeader().method_ids_size) { // invoke-custom continue;// 調用的索引有可能是FFFFFE,防止其他意外情況, 過濾掉非正常methodId } String mtd = dexfile.getNameByMethodId(methodId); for(String magic : magiclist) { if(mtd.indexOf(magic) != -1) { changer.setNop(insns); System.out.println(insns.getFileOff() + " - invoke method " + mtd); } } } } changer.flush();
最后一步就是重建DexHeader,主要就是計算signature和checksum,這個應該不用多說什么:
public void flush() { super.flush(); // 先將修改的數據flush,否則this.data還是舊數據 DexHeader header = dexFile.getHeader(); try { this.move(0); MessageDigest mdTemp = MessageDigest.getInstance("SHA1"); mdTemp.update(this.data, 32, this.data.length - 32); header.signature = mdTemp.digest(); // 計算Signature System.arraycopy(header.signature, 0, this.data, 12, 20); // 覆蓋原Signature Adler32 checksum = new Adler32(); checksum.update(this.data, 12, this.data.length - 12); header.checksum = (int) checksum.getValue(); // 計算checksum } catch (NoSuchAlgorithmException e) { System.out.println("[*E]" + "rebuild" + ":" + e.getMessage()); } catch (CursorMoveException e) { System.out.println("[*E]" + "rebuild" + ":" + e.getMessage()); } this.changeData(header.magic); this.changeInt(header.checksum); this.changeData(header.signature); this.changeInt(header.file_size); this.changeInt(header.header_size); this.changeInt(header.endian_tag); this.changeInt(header.link_size); this.changeInt(header.link_off); this.changeInt(header.map_off); this.changeInt(header.string_ids_size); this.changeInt(header.string_ids_off); this.changeInt(header.type_ids_size); this.changeInt(header.type_ids_off); this.changeInt(header.proto_ids_size); this.changeInt(header.proto_ids_off); this.changeInt(header.field_ids_size); this.changeInt(header.field_ids_off); this.changeInt(header.method_ids_size); this.changeInt(header.method_ids_off); this.changeInt(header.class_defs_size); this.changeInt(header.class_defs_off); this.changeInt(header.data_size); this.changeInt(header.data_off); super.flush(); }
super.flush()已經包括了寫出文件,那么現在,把修改后的dex重新壓縮回你的apk里,然后簽個名就可以安裝跑起來啦~(這個也是可以自動化的,但是我沒精力寫了,就交給你們吧^_^)。
尾記
如此這般,核心的東西已經有了,后面的部分就請盡情發揮吧。 其實我本來想詳細寫一下Dex格式的,但是突然懶癌病發,而且關于Dex的資料已經夠多了,再有不明白的地方還可以看源碼。
最后附上幾個去廣告成品:http://hluwa.cn/down/
源碼地址:https://github.com/Hoimk/DexChanger
|