0%

如何屏蔽 Universal Link 调端?

苹果在 iOS 9 开始提供 Universal Link 唤起 App 的能力。Universal Link 时机上是一个 https 链接,在用户点击网页中的 https 链接时,先尝试唤起 App,唤起失败则加载对应的网页。

相比于使用 scheme:// 这种 scheme 的方式唤起 App,Universal Link 唤起的优点在于苹果并未提供接口进行拦截,一般 App 不会进行拦截。并且在唤起失败时,能自动加载对应的页面。

因此,业内使用 Universal Link 唤起 App的逐渐增多。用户被恶意导流至其他 App 的问题日益严重。本文研究 iOS Universal Link 唤起 App 的逻辑,找到拦截 Universal Link 的方案。

查过一番资料后,得知唤起 App 会调用 +[LSAppLink openWithURL:completionHandler:] 方法。
经过测试,不同固件,不同调端方法和类型的表现也有一些差别,如下:

固件 方法 测试结果
iOS 9.2 -[UIApplication openURL:] 调用了 +[LSAppLink openWithURL:completionHandler:]
iOS 9.2 点击页面a链接(UIWebView) 调用了 +[LSAppLink openWithURL:completionHandler:]
iOS 10.3.3 -[UIApplication openURL:] 不调用 LSAppLink
iOS 10.3.3 -[UIApplication openURL:options:completionHandler:] && options = @{} 不调用 LSAppLink
iOS 10.3.3 -[UIApplication openURL:options:completionHandler:] && options = @{UIApplicationOpenURLOptionUniversalLinksOnly : @(YES)} 调用了 +[LSAppLink openWithURL:completionHandler:]
iOS 10.3.3 点击页面a链接(UIWebView) 调用了 +[LSAppLink openWithURL:completionHandler:]

从这测试结果,我们可以总结出以下结论:
  1. UIWebView发起的 App 唤起,都会调用到 +[LSAppLink openWithURL:completionHandler:]
  2. openURL的方式唤起 App,也有可能会经过 +[LSAppLink openWithURL:completionHandler:],因此最后拦截 UL 唤起的时候需要排除调来自 openURL 的,避免主动唤起时仍旧被拦截。

既然知道 UL 唤起一定会调用+[LSAppLink openWithURL:completionHandler:],那么就看下这个方法是如何实现的,以决定是否可以通过hook这个方法来拦截 UL 唤起。

通过 OC Runtime,hook +[LSAppLink openWithURL:completionHandler:] 方法,可以发现,第一个参数为NSURL *类型,第二个参数为blockblock接收两个参数,BOOLNSError *
因此,完整的声明为:

1
+ (void)openWithURL:(NSURL *)url completionHandler:(void(^)(BOOL, NSError *))handler;

从这方法声明上可以大概看出,尝试 UL 唤起后,唤起结果通过 handler 回调,如果唤起失败,UIWebView 再加载对应的网页。

hook 方法后,不进行任何操作,只回调 handler,网页也是可以正常加载。这也验证了刚刚的猜想。

1
2
3
4
5
+ (void)myopenWithURL:(id)url completionHandler:(id)handler
{
NSError *tmpErr = [NSError errorWithDomain:@"NSOSStatusErrorDomain" code:-5500 userInfo:@{}];
((void (^)(BOOL, id))(handler))(NO, tmpErr);
}

反编译系统lib

LSAppLink 类在 MobileCoreService.framework 中。

通过 ssh 连接到越狱的测试手机,执行find / | grep MobileCoreService定位到/System/Library/Frameworks/路径下有一堆 framework。通过 scp 将 Frameworks 全部拷贝到 mac 上,然而发现这些 framework 中都只包含一些 strings,并没有 lib!

参考 Where are the iOS frameworks binaries located in the filesystem?

The binaries no longer exist on-device (and have not since iOS 3.1): Apple has merged them all into one large mmap()’ed cache file, to make app launch a bit more efficient. As the pages usually never change, the kernel can effectively share them between every running image. You can still use dlopen() on files held within the cache, as dyld short-circuits file lookup when the given library exists in the cache.

The cache file is in /System/Library/Caches/com.apple.dyld, and is named after the architecture (armv6 or armv7). The libraries within can be extracted using dsc_extractor or KennyTM’s dyld_decache, available in this repository, but once extracted they can’t actually be loaded into memory properly (as they all effectively get their symbol tables merged in the cache.)

系统做过优化,所有的 framework 合并成一个文件了。在/System/Library/Caches/com.apple.dyld/目录下,找到了 dyld,通过 scp 拷贝到 mac 上。

通过 Hopper 反编译 dyld,需要配置 DYLDSharedCache.hopperLoader 才能正常反编译,否则会有大量的<redacted>

+[LSAppLink openWithURL:completionHandler:]反编译后的伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
int +[LSAppLink openWithURL:completionHandler:](int arg0, int arg1, int arg2, int arg3) {
*(r31 + 0xffffffffffffffc0) = r24;
*(r31 + 0xffffffffffffffc8) = r23;
*(r31 + 0xffffffffffffffd0) = r22;
*(r31 + 0xffffffffffffffd8) = r21;
*(r31 + 0xffffffffffffffe0) = r20;
*(r31 + 0xffffffffffffffe8) = r19;
*(r31 + 0xfffffffffffffff0) = r29;
*(r31 + 0xfffffffffffffff8) = r30;
r29 = r31 + 0xfffffffffffffff0;
r31 = r31 + 0xffffffffffffffc0 - 0x50;
r19 = arg3;
r21 = arg2;
r22 = arg1;
r20 = arg0;
if (__LSIsServer() != 0x0) {
if (loc_1800b9bc0(*(@selector(startOpenOperation:connection:) + 0x8e0), *0x19d2848b8, "/BuildRoot/Library/Caches/com.apple.xbs/Sources/CoreServices/CoreServices-727.6.2/LaunchServices.subprj/Source/LaunchServices/Workspace/LSAppLink.mm") != 0x0) {
asm{ csel x23, x0, x8 };
}
r0 = *(@selector(startOpenOperation:connection:) + 0x958);
loc_1800b9bc0(r0, *0x19d2848c0);
*(r31 + 0x18) = r31;
*(r31 + 0x20) = r31;
*(r31 + 0x8) = r31;
*(r31 + 0x10) = r31;
r31 = r31;
loc_1800b9bc0();
}
r0 = *(@selector(startOpenOperation:connection:) + 0xa30);
loc_1800b9bc0(r0, *0x19d2841a0);
loc_1800b9bc0();
r22 = loc_1800b9bc0();
loc_1800b9bc0();
r21 = loc_1800b9bc0(r22, *(@selector(startOpenOperation:connection:) + 0x198));
r0 = loc_1800b9bc0(r20, *(@selector(startOpenOperation:connection:) + 0x158));
*(r31 + 0x28) = *___destroy_helper_block_160;
*(r31 + 0x30) = zero_extend_64(0xc200);
*(r31 + 0x38) = r31;
*(r31 + 0x38) = 0x181ee8da4;
*(r31 + 0x40) = 0x199b20ec0;
*(r31 + 0x48) = r19;
r0 = __LSOpenAppLink(r21, r0, r31 + 0x28);
r31 = r29 - 0x30;
return r0;
}

可以看到,+[LSAppLink openWithURL:completionHandler:]最后调用了__LSOpenAppLink方法来唤起。

依据__LSOpenAppLink的汇编和Hopper提供的伪代码,整理出一份比较直观的伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
void _LSOpenAppLink(NSData* data, OS_dispatch_queue *queue, (void (^)(BOOL, NSError *)) handler)
{
if (data && queue && handler)
{
OS_xpc_dictionary *xpcInfo = xpc_dictionary_create(0, 0, 0); // 0x000000013ce69b50
if (xpcInfo)
{
xpc_dictionary_set_int64(xpcInfo, "LSXPCMessage", 0x25);
_LSXPCDictionarySetCFObject(xpcInfo, "LSData", );
xpc_dictionary_set_mach_recv(xpcInfo, "LSXPCKey4", SBSCreateClientEntitlementEnforcementPort());
/*
此时,xpcInfo的内容为:
<OS_xpc_dictionary: dictionary[0x13ce69b50]: { refcnt = 1, xrefcnt = 1, count = 3, dest port = 0x0 } <dictionary: 0x13ce69b50> { count = 3, contents =
"LSXPCMessage" => <int64: 0x13cd85350>: 37
"LSData" => <data: 0x13ce40450>: { length = 1379 bytes, contents = 0x7b2255524c223a2268747470733a5c2f5c2f626f7865722e... }
"LSXPCKey4" => <mach receive right: 0x13ce47fe0> { name = 0xae3f, right = receive }
}>
*/

OS_xpc_connection *connection = _LSCopyServerConnection(xpcInfo);
xpc_connection_send_message_with_reply(connection, xpcInfo, queue, handler);

xpc_release(connection);
xpc_release(xpcInfo);
// go to 0x182e23430
}
else
{
// go to 0x182e23430
}
}
else
{
// go to 0x182e23408
if (handler)
{
// invoke handler
}
else
{
// go to 0x182e23430
}
}
}

可以看到,_LSOpenAppLink函数的主要逻辑为:

  1. 创建 xpc 调用需要的字典,填充相关字段
  2. 创建 xpc connection
  3. 发送 xpc 消息

+[LSAppLink openWithURL:completionHandler:]的 comletionHandler 中加断点,可以看到xpc回调结束后的栈为:

fa9139d4b5cdadff4398b0fbf1771ea5

分析到这里,可以得出初步结论:

  1. 系统在 xpc 调用前也无法确定 URL 是否能 UL 唤起 App。
  2. LSAppLink之前是 WebKit 调用,在+[LSAppLink openWithURL:completionHandler:]之后是 C 方法 xpc 调用,因此 hook +[LSAppLink openWithURL:completionHandler:]进行拦截 UL 是最合适的方法。

结论

综合以上的分析,最后确定拦截方案为:

  1. 在 UIWebView 中的 shouldStartLoad 中判断 UL 唤起的前置条件判断,例如 https 链接、naviationType判断。如果满足前置条件,hook LSAppLink方法。
  2. 在hook的LSAppLink方法中,通过下发名单判断是否允许 UL 唤起,如果不允许则直接回调completionHandler。
  3. 为尽量减少审核风险,拦截完后将hook恢复。

⚠️ 此方案hook了系统私有API,有比较大的审核风险,须配合其他加密手段来实现。

其他

  1. Universal Link 需要在服务器根目录放一个配置文件:apple-app-site-association,其中 appID 字段的格式为teamID.bundle,例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    {
    "applinks": {
    "apps": [],
    "details": [
    {
    "appID": "teamID.com.yyy.iphone",
    "paths": [ "/path/*" ]
    }
    ]
    }
    }
  2. 排查 UL 调起问题时,请注意是否有 https 抓包,如果抓取 https UL 链接,会导致UL无法调端

  3. WKWebView 和 UIWebView 行为存在差异。WKWebView 支持点击后中间页通过window.location.replace方式 UL 调端,而 UIWebView 不支持此种方式。

参考 && 资料

  1. iOS-Runtime-Headers
  2. RuntimeBrowser
  3. DYLDSharedCache.hopperLoader
  4. fix IDA dyld loader