iOS内购-防越狱破解刷单
2019 - 03 - 06
Posted by 羽化归来
iOS内购开发大家一定不陌生,网上类似的文章能搜出千八百篇。大部分都是围绕着如何实现?如何防止漏单丢单说明的。很少有提及到越狱的,即使偶尔有一两篇说越狱,也是简单的三言两语说 为了安全,我们直接屏蔽了越狱手机的内购功能。巴拉巴拉… 以前我也是这么想的,直到上个周末发现我们的内购被破解了 才有了这篇文章。本篇文章就是来讲述越狱下的内购如何防止被破解。
首先我们先简单理一下整个内购的核心流程:
- 客户端发起支付订单
- 客户端监听购买结果
- 苹果回调订单购买成功时,客户端把苹果给的receipt_data和一些订单信息上报给服务器
- 后台服务器拿receipt_data向苹果服务器校验
- 苹果服务器向返回status结果
- 服务器发现订单校验成功后,会把这笔订单存起来,receipt_data用MD5值映射下,保存到数据库,防止同一笔订单,多次发放内购商品。
以上应该是主流的校验流程。当然客户端其中会插一些丢单漏单的逻辑校验,因为那些跟本篇文章无关,所以不在此展开。
status 状态码:
0 成功
21000 App Store无法读取你提供的JSON数据
21002 收据数据不符合格式
21003 收据无法被验证
21004 你提供的共享密钥和账户的共享密钥不一致
21005 收据服务器当前不可用
21006 收据是有效的,但订阅服务已经过期。当收到这个信息时,解码后的收据信息也包含在返回内容中
21007 收据信息是测试用(sandbox),但却被发送到产品环境中验证
21008 收据信息是产品环境中使用,但却被发送到测试环境中验证
从上面的流程可以看出,整个内购的核心其实就是receipt_data。苹果回调给客户端,客户端上报给服务器,服务器拿到后去向苹果服务器校验,苹果服务器再返回给我们服务器订单结果。其实严格来说,整个流程是没问题的。整个的漏洞就在【苹果服务器再返回给我们服务器订单结果】。receipt_data在越狱环境下是可以被插件伪造的,后台向苹果验证时,居然还能验证通过。是的,你没看错,苹果这里有个贼鸡儿坑的地方。这是最坑最坑的地方,伪造的receipt_data苹果校验也返回支付成功
如何解决?我们先来看下越狱订单和正常订单对比
越狱订单receipt_data向苹果服务器校验后如下:
{
"status": 0,
"environment": "Production",
"receipt": {
"receipt_type": "Production",
"adam_id": 1377028992,
"app_item_id": 1377028992,
"bundle_id": "*******【敏感信息不给看】*******",
"application_version": "3",
"download_id": 80042231041057,
"version_external_identifier": 827853261,
"receipt_creation_date": "2018-07-23 07:30:45 Etc/GMT",
"receipt_creation_date_ms": "1532331045000",
"receipt_creation_date_pst": "2018-07-23 00:30:45 America/Los_Angeles",
"request_date": "2018-07-23 07:33:54 Etc/GMT",
"request_date_ms": "1532331234485",
"request_date_pst": "2018-07-23 00:33:54 America/Los_Angeles",
"original_purchase_date": "2018-07-01 12:16:21 Etc/GMT",
"original_purchase_date_ms": "1530447381000",
"original_purchase_date_pst": "2018-07-01 05:16:21 America/Los_Angeles",
"original_application_version": "3",
"in_app": [ ]
}
}
正常订单receipt_data向苹果服务器校验后如下:
{
{
"status": 0,
"environment": "Production",
"receipt": {
"receipt_type": "Production",
"adam_id": 1377028992,
"app_item_id": 1377028992,
"bundle_id": "*******【敏感信息不给看】*******",
"application_version": "3",
"download_id": 36042096097927,
"version_external_identifier": 827703432,
"receipt_creation_date": "2018-07-10 13:54:27 Etc/GMT",
"receipt_creation_date_ms": "1531230867000",
"receipt_creation_date_pst": "2018-07-10 06:54:27 America/Los_Angeles",
"request_date": "2018-07-23 08:03:27 Etc/GMT",
"request_date_ms": "1532333007664",
"request_date_pst": "2018-07-23 01:03:27 America/Los_Angeles",
"original_purchase_date": "2018-06-13 06:52:13 Etc/GMT",
"original_purchase_date_ms": "1528872733000",
"original_purchase_date_pst": "2018-06-12 23:52:13 America/Los_Angeles",
"original_application_version": "5",
"in_app": [
{
"quantity": "1",
"product_id": "*******【敏感信息不给看】*******",
"transaction_id": "160000477610856",
"original_transaction_id": "160000477610856",
"purchase_date": "2018-07-10 13:54:27 Etc/GMT",
"purchase_date_ms": "1531230867000",
"purchase_date_pst": "2018-07-10 06:54:27 America/Los_Angeles",
"original_purchase_date": "2018-07-10 13:54:27 Etc/GMT",
"original_purchase_date_ms": "1531230867000",
"original_purchase_date_pst": "2018-07-10 06:54:27 America/Los_Angeles",
"is_trial_period": "false"
}
]
}
}
看完两笔订单的对比我相信大家可以清楚的知道,越狱订单虽然状态返回是成功的,但是in_app这个参数是空的。大概查了一下。iOS7以下是没有这个in_app参数的,iOS7以上是有的。因为现在App基本支持的起步都是iOS8 iOS9了,iOS7可以不用管了。但这里还有一个问题,就是in_app这个字段并不总是只返回一个,有可能会返回多个,比如下面的这种订单。
正常订单receipt_data校验后 in_app多个元素时:
{
"status":0,
"environment":"Sandbox",
"receipt":{
"receipt_type":"ProductionSandbox",
"adam_id":0,
"app_item_id":0,
"bundle_id":"*******【敏感信息不给看】*******",
"application_version":"1",
"download_id":0,
"version_external_identifier":0,
"receipt_creation_date":"2018-07-24 04:28:24 Etc/GMT",
"receipt_creation_date_ms":"1532406504000",
"receipt_creation_date_pst":"2018-07-23 21:28:24 America/Los_Angeles",
"request_date":"2018-07-24 04:30:06 Etc/GMT",
"request_date_ms":"1532406606695",
"request_date_pst":"2018-07-23 21:30:06 America/Los_Angeles",
"original_purchase_date":"2013-08-01 07:00:00 Etc/GMT",
"original_purchase_date_ms":"1375340400000",
"original_purchase_date_pst":"2013-08-01 00:00:00 America/Los_Angeles",
"original_application_version":"1.0",
"in_app":[
{
"quantity":"1",
"product_id":"*******【敏感信息不给看】*******",
"transaction_id":"1000000398911598",
"original_transaction_id":"1000000398911598",
"purchase_date":"2018-05-16 03:26:12 Etc/GMT",
"purchase_date_ms":"1526441172000",
"purchase_date_pst":"2018-05-15 20:26:12 America/Los_Angeles",
"original_purchase_date":"2018-05-16 03:26:12 Etc/GMT",
"original_purchase_date_ms":"1526441172000",
"original_purchase_date_pst":"2018-05-15 20:26:12 America/Los_Angeles",
"is_trial_period":"false"
},
{
"quantity":"1",
"product_id":"*******【敏感信息不给看】*******",
"transaction_id":"1000000398911640",
"original_transaction_id":"1000000398911640",
"purchase_date":"2018-05-16 03:26:37 Etc/GMT",
"purchase_date_ms":"1526441197000",
"purchase_date_pst":"2018-05-15 20:26:37 America/Los_Angeles",
"original_purchase_date":"2018-05-16 03:26:37 Etc/GMT",
"original_purchase_date_ms":"1526441197000",
"original_purchase_date_pst":"2018-05-15 20:26:37 America/Los_Angeles",
"is_trial_period":"false"
},
{
"quantity":"1",
"product_id":"*******【敏感信息不给看】*******",
"transaction_id":"1000000398911784",
"original_transaction_id":"1000000398911784",
"purchase_date":"2018-05-16 03:26:50 Etc/GMT",
"purchase_date_ms":"1526441210000",
"purchase_date_pst":"2018-05-15 20:26:50 America/Los_Angeles",
"original_purchase_date":"2018-05-16 03:26:50 Etc/GMT",
"original_purchase_date_ms":"1526441210000",
"original_purchase_date_pst":"2018-05-15 20:26:50 America/Los_Angeles",
"is_trial_period":"false"
},
{
"quantity":"1",
"product_id":"*******【敏感信息不给看】*******",
"transaction_id":"1000000398911801",
"original_transaction_id":"1000000398911801",
"purchase_date":"2018-05-16 03:27:22 Etc/GMT",
"purchase_date_ms":"1526441242000",
"purchase_date_pst":"2018-05-15 20:27:22 America/Los_Angeles",
"original_purchase_date":"2018-05-16 03:27:22 Etc/GMT",
"original_purchase_date_ms":"1526441242000",
"original_purchase_date_pst":"2018-05-15 20:27:22 America/Los_Angeles",
"is_trial_period":"false"
},
{
"quantity":"1",
"product_id":"*******【敏感信息不给看】*******",
"transaction_id":"1000000399060767",
"original_transaction_id":"1000000399060767",
"purchase_date":"2018-05-16 11:10:45 Etc/GMT",
"purchase_date_ms":"1526469045000",
"purchase_date_pst":"2018-05-16 04:10:45 America/Los_Angeles",
"original_purchase_date":"2018-05-16 11:10:45 Etc/GMT",
"original_purchase_date_ms":"1526469045000",
"original_purchase_date_pst":"2018-05-16 04:10:45 America/Los_Angeles",
"is_trial_period":"false"
},
{
"quantity":"1",
"product_id":"*******【敏感信息不给看】*******",
"transaction_id":"1000000399061778",
"original_transaction_id":"1000000399061778",
"purchase_date":"2018-05-16 11:14:52 Etc/GMT",
"purchase_date_ms":"1526469292000",
"purchase_date_pst":"2018-05-16 04:14:52 America/Los_Angeles",
"original_purchase_date":"2018-05-16 11:14:52 Etc/GMT",
"original_purchase_date_ms":"1526469292000",
"original_purchase_date_pst":"2018-05-16 04:14:52 America/Los_Angeles",
"is_trial_period":"false"
},
...
]
}
}
综上,整个服务器那边校验逻辑应该是这样的。 首先客户端必须要给服务器传的三个参数:receipt_data, product_id ,transaction_id
//该方法为监听内购交易结果的回调
- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray<SKPaymentTransaction *> *)transactions
transactions 为一个数组 遍历就可以得到 SKPaymentTransaction 对象的元素transaction。然后从transaction里可以取到以下这两个个参数,product_id,transaction_id。另外从沙盒里取到票据信息receipt_data
我们先看怎么取到以上的三个参数
//获取receipt_data
NSData *data = [NSData dataWithContentsOfFile:[[[NSBundle mainBundle] appStoreReceiptURL] path]];
NSString * receipt_data = [data base64EncodedStringWithOptions:0];
//获取product_id
NSString *product_id = transaction.payment.productIdentifier;
//获取transaction_id
NSString * transaction_id = transaction.transactionIdentifier;
这是我们必须要传给服务器的三个字段。以上三个字段需要做好空值校验,避免崩溃。 下面我们来解释一下,为什么要给服务器传这三个参数
receipt_data:这个不解释了 大家都懂 不传的话 服务器根本没法校验
product_id:这个也不用解释 内购产品编号 你不传的话 服务器不知道你买的哪个订单
transaction_id:这个是交易编号,是必须要传的。因为你要是防止越狱下内购被破解就必须要校验in_app这个参数。而这个参数的数组元素有可能为多个,你必须得找到一个唯一标示,才可以区分订单到底是那一笔。
所以服务器那边逻辑就很清晰了
- 首先判断订单状态是不是成功。
- 如果订单状态成功在判断in_app这个字段有没有,没有直接就返回失败了。如果存在的话,遍历整个数组,通过客户端给的transaction_id 来比较,取到相同的订单时,对比一下bundle_id ,product_id 是不是正确的。
- 如果以上校验都正确再去查询一下数据库里这笔订单是不是存在,如果存在也是返回失败,避免重复分发内购商品。如果都成功了,再把这笔订单充值进去,给用户分发内购商品。
注意:一定要告诉后台,不论校验是否成功,只要客户端给服务器传了receipt_data等参数就一定要保存到数据库里。
为啥什么要保存receipt_data
以上的校验步骤,可以有效的防止内购破解,下面内容是我看苹果官方能文档的关于in_app这个参数说明和解释下为啥服务器必须要保存每一个不同的receipt_data。
In the JSON file, the value of this key is an array containing all in-app purchase receipts based on the in-app purchase transactions present in the input base-64 receipt-data. For receipts containing auto-renewable subscriptions, check the value of the latest_receipt_info key to get the status of the most recent renewal.
大概意思是说: 在这个JSON文件中,这个键的值是一个数组,该数组包含基于base-64后的所有内购收据。如果你的内购类型是自动更新订阅,那么请通过检查latest_receipt_info键的值,来确定最近更新的状态。
很有意思的是,苹果还特别标明了这么一句话:
Note: An empty array is a valid receipt.
也就是说这个in_app参数可能为空,如果为空的话,也需要把这笔交易认为是有效的交易。这是苹果建议的操作。当然我们肯定不能这么干,这个参数是必须必须要校验的,不然越狱环境下,分分钟就把你内购破解了。我去校验了很多正常用户的内购订单,没发现一个in_app参数是为空的。但为了保险,还是让后台把所有前端传的receipt_data等参数不管成功失败都保存下来,万一哪个用户因此投诉充值不到账,我们有据可查。
下面两段话
The in-app purchase receipt for a consumable product is added to the receipt when the purchase is made. It is kept in the receipt until your app finishes that transaction. After that point, it is removed from the receipt the next time the receipt is updated - for example, when the user makes another purchase or if your app explicitly refreshes the receipt.
The in-app purchase receipt for a non-consumable product, auto-renewable subscription, non-renewing subscription, or free subscription remains in the receipt indefinitely.
大概意思是说: 每当有一笔交易发起的时候,in_app里就会添加收据的一些信息。这些信息会一直保存直到你结束这笔交易。在此之后,下次更新收据时会将其从收据中删除 - 例如,当用户再次购买时,或者您的应用明确刷新收据时。
非消耗型项目,自动续期订阅,非续期订阅或免费订阅的应用内购买收据将无限期保留在收据中。
这一点也解释了说,为什么in_app这个数组有时候会有多个元素。
相关问题
Q1:内购和Apple Pay的区别?
A1:内购是内购,Apple Pay是Apple Pay。我不知道有多少人第一次接触时,会把这俩概念混淆掉,这里你可以简单这么理解,虚拟的物品就是用内购,实际的物品就是用Apple Pay。Apple Pay是一种支付方式,你可以类比为支付宝,微信那种。但人家只支持实际物品,如果你东西是虚拟的话,你却集成Apple Pay上架是要被拒绝的哦~当然反过来,实际物品你却集成内购上架,也是一样被拒。对于大部分的国内开发者而言,你很少会遇到需要集成Apple Pay的App的。能用支付宝/微信的场景还要求支持Apple Pay的产品毕竟是少数。
Q2:内购项目的类型区别?
A2:首先内购项目分为以下4种,消耗型项目,非消耗型项目,自动续期订阅,非续期订阅。我们来一个个介绍。
消耗型项目:只可使用一次的产品,使用之后即失效,必须再次购买。就是大家最广为所知的虚拟币,比如直播平台斗鱼的鱼翅,熊猫的竹子,哔哩哔哩的B币等,这个概念大家应该很好理解,不过多解释了。
非消耗型项目:只需购买一次,不会过期或随着使用而减少的产品。这个一般是游戏那里用的多,一般是付费解锁关卡的场景,用户买过一次,卸载重装或者同一个Apple id但换App账号时,也要能保证用户重新获得该内购商品。所以App内部需要额外去实现恢复购买的逻辑。
自动续期订阅:允许用户在固定时间段内购买动态内容的产品。除非用户选择取消,否则此类订阅会自动续期。iTunces上给的示例是:每月订阅提供流媒体服务的 App。对比我们熟悉的,网易云音乐的内购商品-连续包月黑胶VIP,就是此类型。一般来说,没啥必要不要选这一种,如果是VIP的那种场景推荐下面非续期订阅类型去做。自动续期订阅的坑非常多,比另外几种内购类型都要复制。
非续期订阅:一般来说VIP可以用这种方法来做订阅,我们公司项目的VIP购买就是这种方式。他的实现方式你可以完全照搬消耗性项目,不用做什么额外处理,也不用去管返回的订阅日期什么的东西,就是以服务器那边为准。服务器的日期开始,服务器的日期结束。既简单又保险,不需要额外的做什么处理。
Q3:VIP一定要用内购做吗?
A3:其实判断你们公司的App到底需不需要用内购,很简单,就是看跟实际物品有没有关系。如果你的VIP功能是类似饿了么这种,点外卖可以打折/多领红包 那么就不需要用内购,上架的时候说清楚就行了。如果你的VIP功能是虚拟的,比如头像更炫酷,尊贵的VIP身份标示,独特的入场动画等等虚拟相关的,比如QQ会员,就必须要用内购去做。需要说明的是,那种是VIP才能和某某用户聊天的场景,是VIP才能得到App里某某用户的服务【语音,视频】时,这一类的场景苹果一样认为是虚拟的,一样要用内购去做。
Q4:VIP内购一定是非续期/自动续期订阅吗?我可不可以用虚拟币购买VIP呢?
A4:这个问题我自己经历过。我的答案是你也可以用虚拟币购买VIP的这种方式,但如果被拒绝,你只能老老实实的按前种方式去做。如果你们的App既有虚拟币又有VIP,产品希望你VIP是直接用虚拟币去购买,这样整个流程都很方便。那么你一定要记住。千万不要在1.0版本这么做,这是血泪教训。1.0版本会抓的很严很严,同时虚拟币+VIP功能,百分百苹果会要求你VIP要用续期订阅去实现。最保险的做法呢,1.0版本不要做任何内购,迭代几个小版本后,加入虚拟币内购,在迭代几个小版本,加入VIP直接用虚拟币购买的功能,这是最最保险的做法。记住:1.0的审核力度是真的很严,能先不做内购就不要做内购,老板或许不懂,1.0版本什么都想要,但往往因为内购,会让你们的产品反复被拒。这一块如果大家感兴趣,可以看看,我的1.0版本就是加了内购,反复被拒5次。血泪教训
Q5:我们老板心疼那百分之30的手续费,我能不能不用内购啊,有没办法绕过内购?
A5:办法是有的。但是有风险。我16年做过绕开内购的方法。思路很简单,就是App里集成支付宝/微信/内购这种功能,后台做控制开关,审核时,开关打开,给审核人员看内购功能,审核通过后,开关关闭,给正常用户是用支付宝/微信功能。这个方法,我17年的时候听到很多群友说不行了,你在上架审核时,苹果会扫描你的包,检测到第三方支付sdk时,会拒绝掉。后来又有群友说可以用H5的方式实现支付功能。另外可能会有别的绕开苹果审核的实现方式,如果有哪位朋友知道,不妨留言告知。但不管是哪种方式,都是有风险的,苹果对内购一直抓的很严,如果让它知道你们在钱的方面上欺骗过他,后果还是很严重的。iOS上的用户付费率还是很不错的,付费意愿基本上可以是安卓用户的十几倍。所以如果你们的产品真的有前景,并且想长久做下去,还是奉劝不要做欺骗苹果的事情了。
Q6:网上有好多讲丢单的博客,看的是一脸懵逼,有的看懂后,在看下一篇又不懂了,感觉都好复杂。
A6:我在刚接触内购的时候也是这样,我觉得有些博客讲的真有点过了,它为了考虑一些用户的极端操作,多出来很多逻辑处理,导致博客异常的复杂,我记得有博客讲必须要把receipt_data等信息存到keychain里,因为用户有可能卸载App,如果你只存到NSUserDefaults里,那样就丢单了。 ……那么有没有这种情况呢?我觉得是肯定有的。但我们来算算几率,首先他内购成功,在向服务器调接口的时候,他手机突然没电了/断网了/程序崩溃了/网络差等的久他自己杀死进程了 巴拉巴拉。 然后他在下一次手机恢复正常的时候,果断卸载掉App,重新去App Store上下载安装,进入App后,发现内购没到账。
网上博客还爱用那种切换账号的场景举例,A内购成功了,但用户各种骚操作后,自己换到B账号,然后服务器那边把商品发到B账号上了,等等。 这些情况都是存在的,因为苹果的内购机制问题,你是不能百分百保证不丢单的,不要把丢单情况看的那么严重,逻辑写的那么复杂。你看看所有大厂的App上都会写充值遇到问题,点我联系客服 巴拉巴拉。关于丢单,我的做法是这样的,在苹果内购成功的回调里,NSUserDefaults存每一笔支付成功的订单,如果服务器校验成功,就把本地存的这笔订单删除。如果没收到服务器的响应,就一直保留。然后每次App启动就会去把本地存的丢单信息扔向服务器校验,校验成功删除,校验失败不管。这里还是看开发时间,当时我写内购功能的时候,预算时间就两天不到,所以写的飞快,就简单的用这个办法去防止丢单,目前来看,没有发现过一笔真正用户充钱但商品没到账的例子。如果大家开发时间充足,可以慢慢去弥补极端操作漏洞。
Q7:内购为什么会有这么多坑啊?看网上好多博客都在说,我自己做微信/支付宝的时候,没感觉有这么多坑啊
A7:苹果的内购坑主要有以下几点
-
applicationUsername该字段可能为nil 导致客户端没办法用这个参数给服务器透传订单编号,来形成一个交易订单号的绑定。
-
校验订单流程是必须服务器主动去询问苹果服务器,而支付宝/微信 却是他们的服务器会在用户支付成功时主动给我们服务器回调。正是这个原因,让iOS开发者饱受折磨,大部分的丢单漏单都是苹果的这个设计造成的。苹果不会主动回调给我们服务器,也就意味着我们服务器需要主动去苹果那里询问这笔订单,到底成没成功。但服务器询问的时机,又是客户端告诉服务器的。这就鸡儿坑了,一些情况下,用户在付费成功后,突然断网了/崩溃了/出现意外了等等,客户端没办法告诉服务器,这就出现了,用户钱成功了,内购商品却没到账。所以网上才会有这么多篇讲防止丢单的博客。
-
越狱下,插件也能破解掉苹果内购,然后校验状态status还返回成功。也就是本篇博客开头讲的那种情况。这一点真的是无力吐槽,亏你特么回调给我的receipt_data那么一大长串,有卵用?
-
苹果的订单机制。苹果为了保护用户隐私,你是看不到一条条流水明细的。你看到的只有👇这种。
-
每一种内购类型的总收入,或者总销量。导致对账查询的时候加了不少麻烦。
-
苹果的退款机制。这个比上面一点更坑,iOS用户,内购了某商品,你可以在完全用完了后,联系苹果客服,说我误操作了巴拉巴拉或者说感觉这个商品不值那么多被开发者欺骗了巴拉巴拉,快给我退款,客服就会温柔的告诉你,不要急,她会帮你处理,1-2个工作日把,你就会发现你的钱就退回来了。没记错的话,一段时间内,一个Apple Id可以申请1-2次。但不能多,多了的话就会被苹果拒绝。而这一切,开发者这边是完全不知情的。你不知道哪个用户退款了,你知道的只是一个图,类似下面的这种。
用户消费了你的内购商品,公司却收不到钱,很多公司的内购服务都是要成本的。如果这种用户一旦多起来,坏账率会飙升,公司就会被活活的拖垮。一个好的项目也就凉凉掉。淘宝上关于iOS内购退款专门有一个超级庞大的黑色产业链。从弄账号到专门联系苹果客服再到道具销赃变现,各司其职,一环套一环,每个环节人都赚的盆满钵满。苦的都是公司,因为苹果没有任何损失,他也不会补偿你公司1毛钱,一切损失都是公司自己承担。没记错的话,15-16年,很多很多游戏公司都是因为这个被活活拖垮的。幸运的是,这种恶意退款一般都是针对游戏公司,因为游戏道具可以快速变现。像正常的App甚少碰到,因为他退款了也没毛用,没法及时变现。毕竟他们可不稀罕跟你们的女用户1v1视频聊天。