TinyPrank:如何在不直接调用的情况下注入代码

Galvin Li
9 min readMar 31, 2019

--

English version also available: TinyPrank: Ways to inject code without direct calling

今天是愚人节,希望你成功捉弄别人,然后能识破别人的捉弄。

愚人节快乐。

本篇完。

别关!别关!说笑而已。这篇文章主要是给大家介绍几个方法在一些不明显的方式把一些方法注入到一个项目中,一方面你可以用这些方法来捉弄一下你的同事,另一方面大家编写生产代码的时候,也可以注意这些情况影响的可能性,编写出更稳健更安全的代码。

在介绍这些方法之前,我希望让大家直接体验一下这些方法的隐秘性。所以我制作了一个demo程序,里面故意添加了4个Prank,希望大家先运行这个demo程序,尝试自行找出Prank的代码,然后再看下面的详细说明内容。

Demo程序地址: https://github.com/bestwnh/TinyPrank

demo程序基于Swift5, Xcode 10.2

demo的基础app是来自objcio/app-architecture里面的Recordings-MVC,正如所有app一样程序本身会有一些bug,但这些都不是重点,大家只要关注在下面说明的几个Prank。

所有Prank代码都在一个commit中添加,同时添加了Alamofire的代码来模拟大量代码commit的情况。

Prank 1:内容消失

导航栏按钮和列表内容消失。

Prank 2:显示内容错乱

一些文本内容会变得不可读。

Prank 3:时间格式变化

时间格式第一次会显示成”0:00:00",之后会显示成”0 seconds”的格式。

Prank 4:单元测试不通过

在不修改任何测试代码的情况下,原本的程序是完全通过的,但添加了Prank代码之后就出现失败了。(这个Prank可能比较棘手)

那开始找寻Prank的所在吧。

解决所有Prank了吗?再往下拉就到解答了哦。

真的要看解答内容了吗?

那我们开始解答吧。

Prank 1:内容消失

关键代码是在ParameterEncoding.swift文件的240行:

class UlNavigationBar: UINavigationBar {
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
NotificationCenter.default.addObserver(self, selector: #selector(show), name: UIApplication.didFinishLaunchingNotification, object: nil)
}
@objc func show() {
UIApplication.shared.keyWindow?.rootViewController = UIStoryboard(name: "LaunchScreen", bundle: nil).instantiateInitialViewController()
}
}

这个Prank的效果其实是无限启动界面,通过通知把程序的keyWindow.rootViewController换成启动界面,而触发的方法是通过Storyboard里面设置自定义类实现的,如下所示:

但你可能也留意到这个自定义类看着就是系统的UINavigationBar,但实际是UlNavigationBar,但Storyboard的字体原因,还有Storyboard的修改commit是可读性很低的,所以也比较难发现。

小结:

通知发出方和接收方关联性不大,难以发现互相触发的代码。

Storyboard的代码commit可读性低,对问题的排查难度大。

Prank 2:显示内容错乱

关键代码是在ResponseSerialization.swift文件的583行:

func NSLocalizedString(_ key: String, tableName: String? = nil, bundle: Bundle = Bundle.main, value: String = "", comment: String) -> String {
let string = key.shuffled().map(String.init).joined()
return Foundation.NSLocalizedString(string, tableName: tableName, bundle: bundle, value: value, comment: comment)
}

这个Prank的效果是所有多语言的文本都会被打乱顺序,通过重新实现全局方法来实现触发。

小结:

这里主要强调全局方法的危险性,因为全局方法是独立的,而且很容易被复写,就可以形成一个很好的注入入口。

Prank 3:时间格式变化

关键代码是在HTTPHeaders.swift文件的314行:

func DateComponentsFormatter() -> Foundation.DateComponentsFormatter {
let formatter = Foundation.DateComponentsFormatter()
DispatchQueue.main.async {
formatter.unitsStyle = .full
formatter.zeroFormattingBehavior = .default
}
return formatter
}

这个Prank的效果是把某个类的初始化调用方法复写成一个全局方法,然后利用异步方法去调整代码执行顺序,实现覆盖原代码配置的效果。其实把类创建方法更换成全局方法是有不同的代码高亮的,但实际上很多代码高亮的配色这两种是非常相似的,一般很难区别。如下图line4与line5的颜色差异(用的是Xcode的Civic主题)。

小结:

复写类的初始化方法或许相对麻烦,但也是可行的,毕竟类初始化方法就是一个全局方法。

异步操作有时候是一种很好的工具,有时候是一种危险的操作。

代码高亮的配色是很重要的。

Prank 4:单元测试不通过

这个Prank应该是最难解决的一个,因为这个问题的代码不在代码文件里面。而是在项目文件里面。但即使我告诉你问题出现在下图里面,你能直接看出异样的地方么?

相信很多人都无法分辨哪些是项目默认的,哪些是我添加上去的,而点开后就能看出差别:

但下面的虽然看到是脚本代码,内容依然没有直接映入眼帘,继续往下拉才会看到确切内容:

cat << 'EOL' >> $TARGET_NAME/AppDelegate.swift
extension Int {
static func > (l:Int, r:Int) -> Bool {
return l < r
}
}
EOL

这段脚本的效果就是往AppDelegate.swift文件里面插入5行代码,用来重载Int>运算符号。

然后配合后面的另一个脚本,删掉前面添加的5行代码:

filename=$TARGET_NAME"/AppDelegate.swift"
delete_line=5
dd if=/dev/null of=$filename bs=1 seek=$(echo $(stat -f=%z $filename | cut -c 2- ) - $( tail -n$delete_line $filename | wc -c) | bc )

唯一要注意的是Compile Sources行为需要在两个脚本之间,确保项目编译文件的时候能读取到插入的代码内容。这样即使你发现了有异样,但你无法直接查找到注入的代码,所以查找到问题的难度会很高。

小结:

项目文件的commit可读性很低,特别是伴随文件变动。

代码不一定都在代码文件,就像我们使用一些第三方服务需要在项目里配置自动执行脚本的时候,我们应该更谨慎。

运算符的重载是一个很有力的工具,但同时也是很危险的操作,debug起来非常困难。

  • 本文用到的代码均可以在GitHub项目里面找到。
  • 如果你对文中的内容有疑问或者建议,欢迎留言讨论。
  • 如果你觉得文中内容有价值,👏可以让更多人看到。
  • 如果你喜欢这类型内容,欢迎follow我的MediumTwitter,我会持续发布更多有用内容给大家。
  • All code in this article can be found in the GitHub project.
  • If you have questions or suggestions, welcome to leave comment for discuss.
  • If you feel this article is valuable, 👏 can make more people can see it.
  • If you like this type of content, welcome to follow my Medium and Twitter, I will keep posting useful content for everyone.

--

--

Galvin Li

A Tiny iOS developer who love to solve problems and make things better.