如何实现 AppStore App 的自动下载

18,107 阅读15分钟

这次的分享是关于如何在 AppStore 实现 App 的自动下载,理想中的目标是只需要一部手机,不需要人来干预,就可以模拟用户的真实下载,并在下载完成以后,可以自动更改手机参数,使之变为另外一部苹果手机,进行周而复始的下载工作。但是呢,本文的内容只包含如何去模拟用户的操作来完成下载,并不涉及抹机、IP 更换等内容。

最终效果见:https://pan.baidu.com/play/video#/video?path=%2F自动下载效果视频.mp4&t=-1

为什么做这个呢?

可能会有人问,为什么要做这么一个项目。主要是两点原因吧,第一点呢,是出于个人兴趣,逆向其实在开发中的用处还是蛮大的,比如帮助我们分析 Apple 操作系统,帮我们做好安全防御。通过这么一个项目的实践,可以加深自己对逆向开发的理解,第二点呢,就是 App Search Optimization 是一个一直比较热门的话题,有白帽子和黑帽子 ASO 之分,通过关键字和标题优化等手段来进行 ASO 的属于白帽子 ASO,而通过刷榜程序来进行 ASO 的属于黑帽子 ASO,ASO 的刷榜脚本是价值不菲的,可能价值几十万甚至几百万。通过这个项目也是小试牛刀,了解下灰产的一些技术手段。

什么是 ASO

ASO 的全称是 App Search Optimization,就是提升你 APP 在 AppStore 排行榜和搜索结果排名的过程。我们经常可以看到 AppStore 有一些奇怪的五星好评,也会遇到搜索关键字,排名第一的是一个看上去完全不相关的 App。这些都是 ASO 优化的手段,帮助提升产品的曝光量。

白帽子 ASO 常用的手段就是通过数据分析,来优化关键词、标题等,进而提高 App 的排名和曝光率。而黑帽子的手段则是,通过刷榜程序来实现 App 的大量搜索、下载、好评这一系列的过程来提升 App 的排名。

常见的刷榜手段主要有两种,一种是机刷,就是通过触动精灵或者代码注入的方式来实现模拟用户的真实操作,进而完成搜索、下载、评论等操作。再一种协议刷,就是破解 AppStore 的登陆、下载相关的网络协议,通过模拟真实的网络请求来实现登陆、下载等行为。据说在刷榜过程中,苹果会校验你的 Apple ID、IP 等信息,所以需要购买大量的 Apple ID 和不断更换 IP 地址。

如何实现 App 的自动下载

想要的效果:

  1. 进入 AppStore,切换 tab 到搜索界面
  2. 设置搜索关键字、搜索
  3. 进入列表页后,点击 App 进入详情页点击下载
  4. 根据提示完成登陆、下载,并在下载完成以后跳转到推荐 Tab
  5. 进入推荐 Tab 后,退出登陆

大概实现步骤:

  1. 准备越狱手机和 Mac 电脑
  2. 砸壳 dumpdecrypted,通常 PP助手、iTools 下载的 App 是经过砸壳的,同时 AppStore App 不需要砸壳
  3. 头文件获取:AppStore class-dump,系统库的头文件的获取:dyld_cache class-dump
  4. 定位关键函数:Reveal、Cycript、lldb
  5. tweak 的注入

砸壳

我们的 App 上传到 AppStore 后,苹果会对 App 进行加密,要想去分析可执行文件,就必须要进行脱壳解密的操作,dumpdecrypted 是一款出色的脱壳工具,它的原理是将 App 运行起来,App 启动时,系统会对 Mach-O 文件进行加载,并完成对应的解密操作,dumpdecrypted 就可以在此时将解密后的 Mach-O dump 出来,从而达到解密的效果。

如果为了省事可以直接从 PP 助手、iTools 上下载对应的 App,一般情况下是已经经过砸壳的。同时,对于 AppStore 这样的系统程序有些特殊,他们 并不需要进行砸壳,可以直接拿来进行分析。

获取头文件

拿到一个砸壳后的可执行文件后,就可以使用 class-dump 来获取可执行文件的所有头文件,class-dump 会对 Mach-O 的格式进行分析,并将信息提取出来形成我们想要的头文件。

AppStore 的可执行文件也略有特殊,class dump之后会发现 AppStore 中包含的代码极少。App Store 的很多关键代码逻辑都不在 AppStore 这个可执行文件当中,而是在系统的动态库中,我们需要分析动态库的头文件信息进而定位到关键函数。可以获取对应系统dyld_cache 中的动态库,然后 dump 出头文件。AppStore UI 有关的逻辑都在 StoreKitUI 动态库中,这个动态库是分析的重点。

Reveal

Reveal 是一款 UI 调试工具,官方的定义是:See your iOS application's view hierarchy at runtime with advanced 2D and 3D visualisations,当然对于逆向安全人员,查看自己 App 的布局是完全不够的,我们可以在 Cydia 中下载 Reveal Loader,在同一网段下,通过 Mac 的 Reveal 和 iOS 上的 Reveal Loader 就可以查看任意 App 的 UI 布局。

但是,有时候我们不仅想要去看这个 UI 布局,还想要去动态调试这个布局,去看它的 Controller 是谁,去挖掘界面下的真正的代码逻辑。这个就涉及到 Cycript 这个工具。

Cycript

Cycript 是由 Cydia 创始人 Saurik 推出的一款脚本语言,它混合了Objective-C 与 JavaScript 两种语法,很容易上手,我们可以通过 Cycript 来进行动态调试,比如查看函数运行的效果,寻找 View 的 Controller 等。

就拿上面 Reveal 详情页为例, Reveal 可以看到获取按钮是 SKUIOfferView,列表页是一个 SKUICollectionView ,那么就通过 Cycript 来看看控制这个 SKUICollectionView 的 Controller 是谁。首先通过 OpenSSH 来连接 iPhone,通过 cycript -p AppStore 来对 AppStore 进行注入调试,UIApp.keyWindow.recursiveDescription().toString() 来打印视图层级。(注:此截图和后面的地址对不上,因为不是同一次打印,大家了解下大概意思就成)

可以发现 SKUICollectionView,并且它的内存地址是 0x13fa00e00,可以通过 cycript 脚本来找到它的 Controller 是哪一个,有多种方案,比如通过它的 delegate 来找,或者通过 nextResponder 来找都可以。


cy# [#0x13fa00e00 delegate]
#"<SKUIStorePageSectionsViewController: 0x140167e00>"

cy# [#0x13fa00e00 nextResponder]
#"<UIView: 0x140f5f540; frame = (0 0; 320 568); autoresize = W+H; layer = <CALayer: 0x140f771c0>>"
cy# [#0x140f5f540 nextResponder]
#"<SKUIStorePageSectionsViewController: 0x140167e00>"

同时也可以借助一些私有 API 来实现快速查找 ViewController,使用[[[UIWindow keyWindow] rootViewController] _printHierarchy].toString(),可以发现打印结果中同样可以找到 SKUIStorePageSectionsViewController

cy# [[[UIWindow keyWindow] rootViewController] _printHierarchy].toString()
`<SKUITabBarController 0x157815400>, state: appeared, view: <UILayoutContainerView 0x156db38e0>
   | <UINavigationController 0x15784d200>, state: disappeared, view: <UILayoutContainerView 0x156e6b240> not in the window
   |    | <SKUIDocumentContainerViewController 0x1578d3c00>, state: disappeared, view: <UIView 0x1580e1aa0> not in the window
   |    |    | <SKUIStackDocumentViewController 0x15812b740>, state: disappeared, view: <UIView 0x1580dc870> not in the window
   |    |    |    | <SKUIStorePageSectionsViewController 0x1578ec000>, state: disappeared, view: <UIView 0x1580f1a30> not in the window
   |    |    |    |    | <SKUIAccountButtonsViewController 0x158654180>, state: disappeared, view: <SKUIAccountButtonsView 0x158654f60> not in the window
   | <UINavigationController 0x157849c00>, state: disappeared, view: <UILayoutContainerView 0x156ec4df0> not in the window
   | <UINavigationController 0x157803600>, state: disappeared, view: <UILayoutContainerView 0x156e80de0> not in the window
   | <UINavigationController 0x15703ea00>, state: appeared, view: <UILayoutContainerView 0x156f114a0>
   |    | <SKUIDocumentContainerViewController 0x157ab2a00>, state: disappeared, view: <UIView 0x158a25930> not in the window
   |    |    | <SKUIStackDocumentViewController 0x158a50690>, state: disappeared, view: <UIView 0x158a2b360> not in the window
   |    |    |    | <SKUIStorePageSectionsViewController 0x1578e6000>, state: disappeared, view: <UIView 0x158a2d4b0> not in the window
   |    | <SKUIDocumentContainerViewController 0x157b5fa00>, state: appeared, view: <UIView 0x158cf70e0>
   |    |    | <SKUIStackDocumentViewController 0x158cf6690>, state: appeared, view: <UIView 0x158cf72b0>
   |    |    |    | <SKUIStorePageSectionsViewController 0x157b4ae00>, state: appeared, view: <UIView 0x158cfb1e0>
   | <UINavigationController 0x157028000>, state: disappeared, view: <UILayoutContainerView 0x156ef1300> not in the window
   |    | <ASUpdatesViewController 0x156f169e0>, state: disappeared, view: <UIView 0x156dbd590> not in the window`

从上面的分析可以知道,SKUICollectionView 的控制器是 SKUIStorePageSectionsViewController,「获取」按钮的类是 SKUIOfferView,下一步是分析头文件,看看有没有可以比较明显的方法可以为我们所用。下载是最关键的一步,那么首先来看看 SKUIOfferView 类的情况,它的头文件大致如此。

#import <StoreKitUI/SKUIItemOfferButtonDelegate-Protocol.h>
#import <StoreKitUI/SKUIViewElementView-Protocol.h>

@class NSMapTable, NSMutableArray, NSString;
@protocol SKUIOfferViewDelegate;

@interface SKUIOfferView : SKUIViewReuseView <SKUIItemOfferButtonDelegate, SKUIViewElementView> {
    unsigned long long _alignment;
    NSMapTable *_buttonElements;
    NSMapTable *_buyButtonDescriptorToButton;
    struct UIEdgeInsets _contentInset;
}
- (void)_buttonAction:(id)arg1;

- (void)itemOfferButtonWillAnimateTransition:(id)arg1;
- (void)itemOfferButtonDidAnimateTransition:(id)arg1;
- (struct CGSize)sizeThatFits:(struct CGSize)arg1;

可以从头文件中看到一个 _buttonAction 方法,感觉上是 「获取」按钮点击后的响应方法,对于这种猜测,可以使用 Cycript 来进行调试,测试一下这个函数执行的效果到底如何 在终端执行 [#0x156c69cc0 _buttonAction:#0x156cb4d20] 后查看效果如下,App 已经开始进行下载了,说明这个方法的效果我们猜对了,在调试过程中,可以多多使用 Cycript 提高效率。

lldb

上面我们使用 Cycript 测试了 _buttonAction 的效果,但是这个方法有一个参数,我们要搞清楚它正确的参数类型,传入正确的值。这时候可以借助 LLDB ,来帮助我们找到这个参数的正确类型。 可以使用 b function 来针对 _buttonAction 方法打断点,然后打印它的参数。

传统的做法是使用LLDB 和 IDA 等工具找到 ASLR 和 基地址等信息,然后计算出符号的地址,这样做起来比较繁琐,还是可以继续使用一些私有方法快速定位 _buttonAction 的符号地址来进行断点。

我们想要断点的方法是 _buttonAction,它所在的类是 SKUIOfferView,那么可以使用 LLDB 输入 po [SKUIOfferView _shortMethodDescription] 来看下效果:(更多强大的黑科技私有函数可以参考这里:http://iosre.com/t/powerful-private-methods-for-debugging-in-cycript-lldb/3414)

(lldb) po [SKUIOfferView _shortMethodDescription]
<SKUIOfferView: 0x1a096ddd8>:
in SKUIOfferView:
	Class Methods:
		+ (void) requestLayoutForViewElement:(id)arg1 width:(double)arg2 context:(id)arg3; (0x194719470)
		+ (CGSize) sizeThatFitsWidth:(double)arg1 viewElement:(id)arg2 context:(id)arg3; (0x1947197a8)
	Properties:
		@property (weak, nonatomic) <SKUIOfferViewDelegate>* delegate;  (@synthesize delegate = _delegate;)
		@property (nonatomic) long metadataPosition;  (@synthesize metadataPosition = _metadataPosition;)
		@property (readonly, nonatomic, getter=isShowingConfirmation) BOOL showingConfirmation;  (@synthesize showingConfirmation = _isShowingConfirmation;)
	Instance Methods:
		- (BOOL) setImage:(id)arg1 forArtworkRequest:(id)arg2 context:(id)arg3; (0x19471a8c8)
		- (BOOL) updateWithItemState:(id)arg1 context:(id)arg2 animated:(BOOL)arg3; (0x19471a8d0)
		- (void) _buttonAction:(id)arg1; (0x19471bb5c)
		- (BOOL) _shouldHideNoticesWithBuyButtonDescriptor:(id)arg1 context:(id)arg2; (0x19471c368)
		- (void) _positionNoticeForItemOfferButton:(id)arg1; (0x19471c234)
(SKUIViewReuseView ...)

可以看到 - (void) _buttonAction:(id)arg1; (0x19471bb5c),那么直接使用 b 0x19471bb5c为 _buttonAction 加断点即可。断点到以后,再打印它的参数,对于 Objective-C 来说消息有两个隐含参数,也就是 self 和 _cmd,那么我们想要的参数就在第三个位置,可以通过 po $x2 来查看它的具体信息(ARM64 下函数的参数是存放在 X0 到 X7 这 8 个寄存器里面的,如果超过8个参数,就会入栈)。


Process 7839 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1 2.1 3.1
    frame #0: 0x000000019471bb5c StoreKitUI`-[SKUIOfferView _buttonAction:]
StoreKitUI`-[SKUIOfferView _buttonAction:]:
->  0x19471bb5c <+0>:  stp    x24, x23, [sp, #-0x40]!
    0x19471bb60 <+4>:  stp    x22, x21, [sp, #0x10]
    0x19471bb64 <+8>:  stp    x20, x19, [sp, #0x20]
    0x19471bb68 <+12>: stp    x29, x30, [sp, #0x30]
Target 0: (AppStore) stopped.
(lldb) po $x0
<SKUIOfferView: 0x1596aae00; frame = (279 74; 26 26); layer = <CALayer: 0x1596676b0>>

(lldb) po $x2
<SKUIItemOfferButton: 0x1596ab260; baseClass = UIControl; frame = (0 0; 26 26); clipsToBounds = YES; alpha = 0.2; tintColor = UIDeviceRGBColorSpace 0.0862745 0.0156863 0.0156863 1; animations = { opacity=<CABasicAnimation: 0x1592e7b20>; }; layer = <CALayer: 0x15967d9c0>>

由上可知,参数类型是 SKUIItemOfferButton,也就是 SKUIOfferView 的 subView,其实点击的是 SKUIItemOfferButton,只是 SKUIItemOfferButton 将处理往上抛而已。

Tweak 注入

Cydia 创始人 Saurik 同时为我们提供了一个 Cydia Substrate 这么一个工具,官方的定义是:The powerful code modification platform behind Cydia。我们可以基于 Cydia Substrate 来开发具有各种功能的代码注入程序。

Cydia Substrate 由 MobileHooker、MobileLoader、Safe mode 三个模块组成。MobileHooker 主要用来替换函数的实现,可以想象成 Runtime 的 Method Swizzle。MobileLoader 是用来加载第三方 dylib 的,我们写的破解程序会在目标程序启动时注入到目标程序。Safe mode 就是安全模式,我们写 tweak 的时候可能会造成 Crash,比如万一造成 SpringBoard 无限 Crash 手机岂不是就没法用了,所以提供了这么一个安全模式。

MobileHooker 提供了一些函数来让我们完成 Hook 的工作,但是我们不直接使用 它们,我们使用基于他们封装的 Logos 工具,Logos 的语法很简单直观,易于上手。比如 %hook 可以指定要 Hook 的类、%orig 可以执行被钩住的函数的原始实现、%new 给一个现成的 class 添加新函数(效果与 class_addMethod 类似)。

Tweak AppStore

那我们来使用 Logos 实现下载的功能,当进入 SKUIStorePageSectionsViewController 页面后,找到下载按钮,然后点击下载,当下载按钮的文字由「获取」变为「打开」,代表下载已完成,然后继续执行后续操作。

%hook SKUIStorePageSectionsViewController
- (void)viewDidAppear:(BOOL)animated {	
    %log;
	%orig;
     
    // 遍历所有子 View,找到 offerButton 、offerView
	[self findAllSubviews:self.view];

	if (offerButton && offerView) {
        // 执行下载操作
	    [offerView _buttonAction:offerButton];
        // 每秒去 check 一下,是否下载完成
	    downloadTimer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(timerAction) userInfo:nil repeats:YES];
	}		
}
%new
-(void)timerAction {
	if ([offerButton.title isEqualToString:@"打开"]) {
        // 发送下载完成的通知
		[[NSNotificationCenter defaultCenter] postNotificationName:@"textChangedAction" object:nil];

		downloadTimer = nil;
	}
}
%new
-(void)findAllSubviews:(UIView *)view
 {
    for (UIView *subView in view.subviews) {
        if (subView.subviews.count) {
            [self findAllSubviews:subView];
        }
        
        if ([subView isKindOfClass:NSClassFromString(@"SKUIOfferView")]) {
			offerView = (SKUIOfferView*)subView;
		}
		if ([subView isKindOfClass:NSClassFromString(@"SKUIItemOfferButton")]) {
			offerButton = (SKUIItemOfferButton*)subView;
		}
    }
}
%end

其他的操作,与上述其实很类似,比如搜索、跳转都是利用静态或者动态分析找到关键函数,通过 tweak 来实现想要的效果即可。其中还有一个较难的点,就是弹窗提示我们登陆怎么办?如何实现自动登录功能?

Tweak SpringBoard

首先,想到的就是在 AppStore App 中注入代码,Hook UIAlertAction 和 UIAlertController 的代码,会发现并没有产生作用。AppStore 中的弹窗不是它来控制的,而是另外一个进程 SpringBoard,所以要想实现 Hook AppStore 的弹窗,必须对 SpringBoard 进行代码注入。

我们正常如果要实现一个这种弹窗,代码一般是这么写

UIAlertController *actionSheet = [UIAlertController alertControllerWithTitle:@"标题" message:@"注释信息" preferredStyle:UIAlertControllerStyleActionSheet];  

UIAlertAction *action1 = [UIAlertAction actionWithTitle:@"标题1" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {  
    NSLog(@"点击了按钮 1");  
}];  
UIAlertAction *action2 = [UIAlertAction actionWithTitle:@"取消" style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) {  
    NSLog(@"点击了按钮 2");  
}];  
  
[actionSheet addAction:action1];  
[actionSheet addAction:action2];  

[self presentViewController:actionSheet animated:YES completion:nil];  

基于上面的代码分析可得,我们要想实现自动登录,就要实现自动点击「使用现有的 Apple ID」执行系统的原 action 操作,然后在账号和密码的 TextField 中填入账号密码,点击「好」执行系统的原始 action 操作。其实可以发现,要执行的 action 其实是在初始化 UIAlertAction 过程中,handler block 中加入的逻辑。那么我们就可以 Hook actionWithTitle:style:handler: 然后将 handler 保存下来,当填写好账号密码后,主动触发 handler 即可。

上面那种方法也可以奏效,但是需要自己额外处理下 alertView 的出现和消失, 为了简单可以直接尝试第二种方法,在分析 UIKit 框架中 UIAlertController 类的头文件时发现 _dismissWithAction:这个方法,然后我就试了一下发现可以完成 dismiss 和 执行 handler 两项功能,所以我就直接使用了这个 API 来模拟点击。核心代码如下:

typedef void(^CDUnknownBlockType)(UIAlertAction *action);
CDUnknownBlockType testBlock;
static UIAlertAction *keepAction;
static int atimers;

%hook UIAlertController
- (void)viewDidAppear:(BOOL)animated {
	%log;
	%orig;

	if ([keepAction.title isEqualToString:@"使用现有的 Apple ID"]) {
        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{

			((void ( *)(id, SEL, UIAlertAction*))objc_msgSend)(self, NSSelectorFromString(@"_dismissWithAction:"),keepAction);
    	});
    } 

	if ([keepAction.title isEqualToString:@"好"]) {
        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
            
        	if (self.textFields.count > 1) {
				self.textFields.firstObject.text = @"joyme0104@163.com";
				self.textFields.lastObject.text = @"Joyme0304&&&";

				((void ( *)(id, SEL, UIAlertAction*))objc_msgSend)(self, NSSelectorFromString(@"_dismissWithAction:"),keepAction);

			}
        });
    }
}
%end

%hook UIAlertAction
+ (id)_actionWithTitle:(id)arg1 descriptiveText:(id)arg2 image:(id)arg3 style:(long long)arg4 handler:(CDUnknownBlockType)arg5 shouldDismissHandler:(CDUnknownBlockType)arg6 {
	id obj = %orig;
	UIAlertAction *action = (UIAlertAction *)obj;
    if ([action.title isEqualToString:@"使用现有的 Apple ID"]) {
        testBlock = arg6;
		keepAction = obj;
    } 
	if ([action.title isEqualToString:@"好"]) {
		testBlock = arg6;
		keepAction = obj;
	}
	return obj;
}
%end

从代码可以看出我们在 Hook UIAlertAction 的 _actionWithTitle 方法时,并没有 Hook actionWithTitle:style:handler: ,因为我测试的时候发现在我操作过程中并没有触发,怀疑是苹果没有使用这个 API,直接使用了下面这个方法。

+ (id)_actionWithTitle:(id)arg1 descriptiveText:(id)arg2 image:(id)arg3 style:(long long)arg4 handler:(CDUnknownBlockType)arg5 shouldDismissHandler:(CDUnknownBlockType)arg6 {
}

Thinking About The Future

适当增加对 App 安全的精力的投入,像现在业界的很多 App 都处于被破解的状态,网上随处可见各种 App 的破解版,比如爱奇艺会员破解、钉钉远程打卡等。从客户端角度出发,需要增加代码混淆、反调试等手段保证运行环境的安全,同时与后端人员合作增加保证网络数据链路、反作弊的手段。

Summary

本文首先介绍了常见的攻击手段:

  1. 通过静态分析和动态分析掌握 App 的内部逻辑,通过代码注入实现我们想要的功能,比如自动下载、自动跳转等功能
  2. 通过分析 App 的网络请求,破解网络协议,模拟真实的网络请求来达到某种目的,比如批量下载,批量评论等功能。

然后介绍了 ASO 的影响因素都有哪些,以及黑帽子和白帽子都是怎么进行 ASO 优化的。最后重点写了如何一步步通过代码注入,实现 AppStore App 的自动登录。