App Store内购机制

如今,不同系统平台都有专属的商店应用,Android平台有Google Play,Windows平台有Windows Store,iOS与macOS平台则有App Store。苹果公司的成功,很大程度上得益于该软件的生态环境App Store。

如何让系统上的软件开发人员真正地受益,是操作系统开发商需要关注的问题。只有系统平台上的软件丰富了,才能吸引更多的用户去使用该操作系统,而只有开发人员在系统上开发的软件能够赚到钱,他们才有动力去为系统开发更多更好的软件。苹果公司的Apple Store就曾经创造了无数软件开发人员的成功神话。而这一切背后,苹果公司首创的软件购买、免费软件内付费,都是它成功的关键所在。

App Store的内购又称为IAP(In-app Purchase),它是所有苹果商店内应用内付费软件使用的基础设施。对于软件开发人员,了解其使用方法与运行机制,对开发高质量的商业软件是很有帮助的。苹果公司没有给出IAP的具体技术细节,但在WWDC大会与SDK的开发文档中详细讲解了如何在软件中集成它。IAP技术基于苹果SDK中的Store Kit,它是系统中的一个框架StoreKit.framework。开发人员通过使用StoreKit提供的API来完成IAP的集成工作。整个框架的工作方式如图1所示。

苹果的应用内付费支持使用多种类型的程序。

为基本功能的软件提供付费后的功能更强大的专业版。

杂志类App购买成功后,支持订阅与下载。

免费游戏提供付费后等级解锁。

在线游戏通过付费购买道具或虚拟财产。

测试应用内付费软件的最简单方法就是下载应用内付费的应用,然后观察它们与其他应用之间的区别。由于集成了应用内付费功能的App,只能通过App Store来发布,因此在测试时,需要先从App Store中下载App。可以发现,通过App Store下载的程序与网络发布的程序最直观的不同是:在App Store中下载的程序,在app的Contents/_MASReceipt目录下会有一个receipt文件。其实,这是一个“凭证文件”,软件通过App Store发布成功后,苹果公司会为它维护一份凭证(Receipt),凭证信息以文件形式进行存储,该文件记录了以下信息。

Purchase Information。存放的软件的购买信息。包括软件的Bundle标识符、版本号、唯一标识以及这些属性值的SHA1哈希值。除此之外,它还包含软件的信用记录(Trusted record)与购买记录(Purchase

Record)。这些数据使用ASN.1进行编码存放。所有这些信息被称为Receipt Payload。

Certificates。存放的Apple Root CA。用于验证Receipt的签名信息。

Signature。签名信息。验证签名,可以检测当前的Receipt是否有效,或者是否已经更新了。苹果在开发文档中指出,开发人员应该在程序启动时,检测Receipt是否有效。如果无效,程序应该调用exit(173)退出,系统收到173退出码后,会自动联网请求去刷新Receipt。相应的Objective-C代码如下:

-(void)applicationWillFinishLaunching:(NSNotification *)notification {

NSURL *receiptURL = [[NSBundle mainBundle] appStoreReceiptURL];

if(![[NSFileManager defaultManager] fileExistsAtPath:[receiptURL path]])

{

exit(173);

}

}

接下来看看如何在程序中集成StoreKit。图9-23所示是应用内付费的步骤,整个应用内付费的开发都围绕它展开。

使用iTunes Connect创建并配置好产品信息后,就可以使用StoreKit提供的API与App Store进行交互了。从图中可以看出,整个交互过程一共发送了两次请求:一次是Makes Products Request,也就是构建产品请求。调用Store Kit向App Store发送产品请求,方法是构建一个SKProductsRequest对象的实例,该对象的作用是接收来自App Store返回的本地化产品信息列表。这些本地化的信息中包含了产品的本地化描述以及价格信息,用来展示给用户。SKProductsRequest构建完成后,再为它设置一个代理delegate,用来处理返回的信息。最后调用它的start()方法。从App Store中下载一个App,查看相应的伪代码,如下所示:

void -[ituShopMAS requestProductData](void * self, void * _cmd) {

r14 = [SKProductsRequest alloc];

rdx = self->_product;

rdx = [NSSet setWithObjects:rdx];

r14 = [r14 initWithProductIdentifiers:rdx];

[r14 setDelegate:self]; //设置代理

rdi = r14;

[rdi start]; //调用start()

return;

}

图9-23 应用内付费的步骤

SKProductsRequest的setDelegate()设置了数据返回处理的代理。它是一个SKProducts RequestDelegate协议,用来处理服务器返回的SKProductsResponse对象。该协议有一个接口方法-productsRequest:didReceiveResponse:,用来处理返回的SKProductsResponse。调用它的products属性会返回一个SKProduct列表,解析列表中的产品信息,然后展示给用户。App Store中一个App的相应伪代码如下:

void -[ituShopMAS productsRequest:didReceiveResponse:](void * self, void * _cmd, void * arg2, void *

arg3) {

r15 = self;

rax = [arg3 products];

var_48 = rax;

if ([rax count] != 0x0) {

r12 = @selector(productIdentifier);

var_38 = @selector(isEqualToString:);

var_30 = @selector(setHidden:);

var_78 = @selector(localizedDescription);

var_40 = @selector(setStringValue:);

var_80 = @selector(localizedTitle);

var_68 = @selector(stringWithFormat:);

var_88 = @selector(alloc);

var_90 = @selector(init);

var_98 = @selector(setFormatterBehavior:);

var_A0 = @selector(setNumberStyle:);

var_A8 = @selector(priceLocale);

var_B0 = @selector(setLocale:);

var_B8 = @selector(price);

var_C0 = @selector(stringFromNumber:);

var_C8 = @selector(release);

var_D0 = @selector(setEnabled:);

var_E8 = @selector(shopNeedsIcon);

rbx = 0x0;

do {

rax = [var_48 objectAtIndex:rbx];

r13 = rax;

rax = _objc_msgSend(rax, r12, rbx);

rcx = *objc_ivar_offset_ituShopMAS__product;

rdx = *(r15 + rcx);

if (_objc_msgSend(rax, var_38, rdx, rcx) != 0x0) {

rdi = r15->_product;

rdx = @"iboostup.premium";

if (_objc_msgSend(rdi, var_38) == 0x0) {

var_50 = rbx;

r12 = r15->lblDescription;

rax = _objc_msgSend(r13, var_78, rdx);

_objc_msgSend(r12, var_40, rax);

_objc_msgSend(r15->lblDescription, var_30, 0x0);

var_70 = r15->lblProductName;

rcx = _objc_msgSend(r13, var_80, 0x0);

rax = _objc_msgSend(@class(NSString), var_68, @"%@ only", rcx);

_objc_msgSend(var_70, var_40, rax);

_objc_msgSend(r15->lblProductName, var_30, 0x0);

rbx = _objc_msgSend(_objc_msgSend(@class(NSNumberFormatter),

var_88, 0x0), var_90, 0x0);

_objc_msgSend(rbx, var_98, 0x410);

_objc_msgSend(rbx, var_A0, 0x2);

rdx = _objc_msgSend(r13, var_A8, 0x2);

_objc_msgSend(rbx, var_B0, rdx);

rdi = r13;

r13 = rdi;

rdx = _objc_msgSend(rdi, var_B8, rdx);

var_70 = _objc_msgSend(rbx, var_C0, rdx);

_objc_msgSend(rbx, var_C8, rdx);

r12 = r15->lblPrice;

rcx = var_70;

rax = _objc_msgSend(@class(NSString), var_68, @"Price: %@", rcx);

_objc_msgSend(r12, var_40, rax);

rdi = r15->lblPrice;

rbx = var_50;

_objc_msgSend(rdi, var_30, 0x0);

_objc_msgSend(r15->btnPurchase, var_D0, 0x1);

_objc_msgSend(r15->lblRestore, var_30, 0x0);

_objc_msgSend(r15->imgIcon, var_30, 0x0);

}

......

}

rax = _objc_msgSend(r13, r12, rdx, rcx);

rdx = @"iboostup.premium";

if (_objc_msgSend(rax, var_38, rdx, rcx) != 0x0) {

var_50 = rbx;

r12 = r15->lblDescriptionPro;

rax = _objc_msgSend(r13, var_78, rdx);

_objc_msgSend(r12, var_40, rax);

_objc_msgSend(r15->lblDescriptionPro, var_30, 0x0);

var_70 = r15->lblProductNamePro;

rcx = _objc_msgSend(r13, var_80, 0x0);

rax = _objc_msgSend(@class(NSString), var_68, @"%@", rcx);

_objc_msgSend(var_70, var_40, rax);

_objc_msgSend(r15->lblProductNamePro, var_30, 0x0);

rbx = _objc_msgSend(_objc_msgSend(@class(NSNumberFormatter), var_88,

0x0), var_90, 0x0);

......

_objc_msgSend(rdi, var_40, rax);

_objc_msgSend(r15->lblPricePro, var_30, 0x0);

_objc_msgSend(r15->btnPurchasePro, var_D0, 0x1);

_objc_msgSend(r15->lblRestorePro, var_30, 0x0);

_objc_msgSend(r15->imgIconPro, var_30, 0x0);

}

_objc_msgSend(r15->boxWait, var_30, 0x1, rcx);

rbx = rbx + 0x1;

} while (rbx < [var_48 count]);

}

return;

}

解析完产品信息,展示给用户。当用户选择好产品点击购买时,就会发出第2次请求:Makes Payment Request,也就是构建付款请求。该请求通过调用SKPaymentQueue的addPayment()方法,添加一个SKPayment对象。例如,某产品点击购买某功能选项的伪代码如下:

void -[ituShopMAS btnPurchaseClicked:](void * self, void * _cmd, void * arg2) {

[self waitUI];

rdi = [SKMutablePayment alloc];

rdi = [rdi init];

r15 = [rdi autorelease];

rdx = self->_product;

[r15 setProductIdentifier:rdx]; //设置产品标识

[r15 setQuantity:0x1];

rdi = [SKPaymentQueue defaultQueue];

rdx = r15;

[rdi addPayment:rdx]; //添加支付请求

return;

}

defaultQueue()方法返回一个单例的SKPaymentQueue实例,它是一个队列结构,由App

Store去处理。操作完成后,产品支付请求就加入到支付队列中了。要想处理支付的状态,例如购买成功、购买失败、购买取消等处理的逻辑,就需要为队列添加一个观察者。当队列中交易的状态被更新,或者当交易从队列中删除的时候,观察者应该能正确及时地处理所有的交易信息,并根据交易的结果为购买成功的用户提供相应的功能。添加观察者的操作要在addPayment()调用前完成,通常是在程序的初始化时完成的,代码如下所示:

void * -[ituShopMAS init](void * self, void * _cmd) {

rbx = self;

rcx = [SKPaymentQueue canMakePayments];

rax = 0x0;

if (rcx != 0x0) {

rbx = [[rbx super] init];

rax = 0x0;

if (rbx != 0x0) {

rbx->_checked = 0x0;

rbx->_failures = 0x0;

if ([NSBundle loadNibNamed:@"ituShopMAS" owner:rbx] != 0x0) {

rdi = rbx->lblCancel;

[rdi setStringValue:@"Cancel"];

[rbx->lblCancel setClickTarget:rbx sel:@selector(lblCancelClicked)];

[rbx->lblRestore setStringValue:@"Restore"];

[rbx->lblRestore setClickTarget:rbx sel:@selector(lblRestoreClicked)];

[rbx->lblRestorePro setStringValue:@"Restore"];

[rbx->lblRestorePro setClickTarget:rbx

sel:@selector(lblRestoreProClicked)];

rax = [SKPaymentQueue defaultQueue]; //获取单例队列实例

[rax addTransactionObserver:rbx]; //添加观察者

}

rax = rbx;

}

}

return rax;

}

添加观察者对象使用addTransactionObserver()方法,它传入的是一个SKPaymentTransaction Observer协议对象,SKPaymentTransactionObserver协议有一系列方法被SKPaymentQueue调用。下面我们分别进行介绍。

1. 处理交易

处理交易包括-paymentQueue:updatedTransactions:与-paymentQueue:removedTransactions:方法。前者在一个或多个交易状态更新时被调用,在目标程序中,它必须实现;后者则在交易移除时被调用,在目标程序中,它的实现是可选的。

这两个方法传入的参数都是一个SKPaymentTransaction类型的数组,每一个SKPayment- Transaction代表着一个支付交易对象,应用程序要明确地处理每个交易对象的返回结果,根据它的transactionState属性来判断交易是否成功。如果transactionState的值是SKPayment- TransactionStatePurchased,则表示交易成功,此时程序应该向用户提供收费成功后的功能;如果transactionState的值为SKPaymentTransactionStateFailed,则表示交易失败,应用程序应该获取交易失败的错误信息并反馈给用户。交易的状态是一个枚举值,定义如下:

enum {

SKPaymentTransactionStatePurchasing, //正在付款

SKPaymentTransactionStatePurchased, //付款成功

SKPaymentTransactionStateFailed, //付款失败

SKPaymentTransactionStateRestored, //交易已恢复

SKPaymentTransactionStateDeferred, //交易已推迟

};

typedef NSInteger SKPaymentTransactionState;

一个典型的-paymentQueue:updatedTransactions:代码的逻辑如下所示:

void -[ituShopMAS paymentQueue:updatedTransactions:](void * self, void * _cmd, void * arg2, void * arg3) {

var_F8 = arg3;

r13 = self;

var_30 = *___stack_chk_guard;

intrinsic_movaps(var_40, 0x0, arg2, arg3);

intrinsic_movaps(var_50, 0x0);

var_60 = intrinsic_movaps(var_60, 0x0);

var_70 = intrinsic_movaps(var_70, 0x0);

rbx = [arg3 countByEnumeratingWithState:var_70 objects:var_F0 count:0x10];

if (rbx == 0x0) goto loc_10001eb00; //如果交易的数量为0,则直接返回

loc_10001ea53:

r14 = *var_60;

goto loc_10001ea5a;

loc_10001ea5a:

r15 = 0x0;

goto loc_10001ea5d;

loc_10001ea5d:

if (*var_60 != r14) { //枚举所有的交易

objc_enumerationMutation(var_F8);

}

r12 = *(var_68 + r15 * 0x8);

rax = [r12 transactionState]; //判断每个交易的transactionState

if (rax == 0x3) goto loc_10001eaa2; //3表示SKPaymentTransactionStateRestored

loc_10001ea90:

if (rax != 0x2) goto loc_10001eaae; //2表示SKPaymentTransactionStateFailed

loc_10001ea96:

rdi = r13;

rsi = @selector(failedTransaction:); //交易失败

goto loc_10001eabe;

loc_10001eabe:

_objc_msgSend(rdi, rsi); //为不同的状态执行不同的选择器方法

goto loc_10001eac7;

loc_10001eac7:

r15 = r15 + 0x1;

if (r15 < rbx) goto loc_10001ea5d;

loc_10001eacf:

rbx = [var_F8 countByEnumeratingWithState:var_70 objects:var_F0 count:0x10];

if (rbx != 0x0) goto loc_10001ea5a;

loc_10001eb00:

if (*___stack_chk_guard != var_30) {

__stack_chk_fail();

}

return;

loc_10001eaae:

if (rax != 0x1) goto loc_10001eac7; //1表示SKPaymentTransactionStatePurchased

loc_10001eab4:

rdi = r13;

rsi = @selector(completeTransaction:); //交易完成

goto loc_10001eabe;

loc_10001eaa2:

rdi = r13;

rsi = @selector(restoreTransaction:); //交易恢复

goto loc_10001eabe;

}

2. 处理恢复交易

恢复交易有两个方法,一个是交易成功后的处理,另一个是失败后的处理。它们分别是-paymentQueueRestoreCompletedTransactionsFinished:与-paymentQueue:restoreCompletedTransactionsFailedWithError:。恢复失败的原因通常是网络或本地的Receipt验证失败。一段典型的处理代码如下:

void -[ituShopMAS paymentQueue:restoreCompletedTransactionsFailedWithError:](void * self, void * _cmd, void * arg2, void * arg3) {

rcx = [arg3 description];

NSLog(@"Restore failed: %@", rcx);

[self->boxWait setHidden:0x0];

[self->lblResult setStringValue:@"Restore failed."];

[self->lblProductName setHidden:0x0];

[self->lblDescription setHidden:0x0];

[self->wvWait setHidden:0x1];

[self->lblPrice setHidden:0x0];

[self->btnPurchase setEnabled:0x1];

[self->lblRestore setHidden:0x0];

rcx = self->_product;

[self sendTxEvent:@"restore-fail" product:rcx];

rax = [NSBundle mainBundle];

rdx = @selector(appStoreReceiptURL);

if (([rax respondsToSelector:rdx] != 0x0) && ([[NSFileManager defaultManager]

fileExistsAtPath:[[[NSBundle mainBundle] appStoreReceiptURL] path], rcx] == 0x0)) {

exit(0xad);

}

return;

}

3. 处理下载动作

处理下载动作只有一个方法-paymentQueue:updatedDownloads:,而且它是可选的,只有提供付费下载与订阅的程序才需要实现它。

了解了API的使用方法,再来分析如何破解它就没那么困难了。首先,应该考虑的是如何做到通用破解,即破解IAP的机制后,可以将同类型的产品一次全部破解!这种想法并不是异想天开,在macOS 10.9系统以前,就曾经出现过这样的破解工具与方法。例如2012年7月,一位名叫Alexy的俄罗斯黑客公布了一个针对macOS系统上的IAP的破解方法。在本地系统中安装两张证书,然后使用一款名为Grim Receiper(死神)的工具,就可以一次性破解App Store中大量支持内购的程序。它的原理是将App Store内购请求的通信地址转向自己搭建的内购验证服务器上,使交易发生变化时transactionState的值永远是SKPaymentTransactionStatePurchased。这类破解行为对苹果公司与软件开发人员的打击是巨大的。之后,苹果公司为了阻止这类行为发生,采取了不少措施,包括联系Alexy网站的ISP关闭Alexy的网站,联系Paypal拒绝为Alexy的公开账号提供转帐服务,修补App Store的程序验证漏洞等。10.9版本后,Grim Receiper变得无效,但Alexy似乎并没有放弃对IAP破解的尝试。其实这位黑客还开发出了针对Android与iOS系统内购的破解工具。之后,Alexy使用比特币来收取世界各地人员的开发捐助,网站的ISP也改为了一个地下服务商。在macOS系统10.11初期,Alexy甚至成功开发出了内购破解工具。不过苹果公司一直没放弃对他的关注,很快就为破解的漏洞打上了补丁。目前,Alexy还在积极地尝试如何破解最新的IAP机制。Grim

Receiper运行效果如图9-24所示。

虽然做到通用破解有些难度,但针对个体内购机制的App破解,难度可能就没这么大了。一个典型的破解思路是:修改-paymentQueue:updatedTransactions:方法的代码逻辑,将交易transactionState为SKPaymentTransactionStateFailed时的代码逻辑改成SKPaymentTransaction- StatePurchased时的代码就可以了。而在具体执行破解操作时,可以使用爆破的手段在程序中进行修改,或者使用Hook技术,对方法的返回结果进行Hook。

本文摘自《macOS软件安全与逆向分析》。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 200,612评论 5 471
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,345评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 147,625评论 0 332
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,022评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,974评论 5 360
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,227评论 1 277
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,688评论 3 392
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,358评论 0 255
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,490评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,402评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,446评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,126评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,721评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,802评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,013评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,504评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,080评论 2 341

推荐阅读更多精彩内容