Cracking a Top-Ranked macOS Screensaver: Tracing the Restore-Purchase Flow to the VIP Flag

About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.

This research is published for educational and defensive purposes. The target sample is anonymized throughout.


Cracking a Top-Ranked macOS Screensaver: Tracing the Restore-Purchase Flow to the VIP Flag

There aren’t many macOS reverse-engineering writeups around, so here’s one. (Support the developers — this is for learning only.)

I recently grabbed a screensaver app that’s supposedly the top-ranked Mac screensaver, and its assets are genuinely good. The catch: downloading more assets requires a purchase. Clicking download pops a purchase window. I’d already finished the crack by the time I went to screenshot it, so I can’t show the window.

The UI is simple — a paywall and an asset library list; downloading an unpurchased asset prompts you to pay.

Tools:

  1. Hopper Disassembler
  2. Frida
  3. class-dump

The idea

For responsiveness, membership status is usually written locally. If we find where the app writes the user status, we can recreate the purchase validation.

UI analysis

The purchase window has two buttons — buy for new users and restore purchase for existing ones. Both are viable entry points; I picked “Restore Purchase”. The binary lives at:

/Applications/Live Wallpaper.app/Contents/MacOS/Live Wallpaper

Run class-dump -H Live Wallpaper up front to generate headers for later.

Drop the binary into Hopper and search for the “Restore Purchase” string — nothing. I guessed the app uses a localization bundle. In the Resources directory there’s a zh-Hans.lproj directory; grep it:

find ./ -name '*.strings' -print|xargs grep 'Restore Purchase'

The localization files even annotate the class names for us. “Restore” maps to a restore method — that’s the restore-purchase logic. Click into it:

The disassembly:

 -[SiShiPurchaseWindowController restorePurchaseAction]:
000000010002650c         push       rbp                                         ; Objective C Implementation defined at 0x1001137f0 (instance method), Begin of try block, DATA XREF=0x1001137f0
000000010002650d         mov        rbp, rsp
0000000100026510         push       r15
0000000100026512         push       r14
0000000100026514         push       r13
0000000100026516         push       r12
0000000100026518         push       rbx
0000000100026519         sub        rsp, 0x58
// calls indicatorView, not important
000000010002651d         mov        r14, rdi
0000000100026520         mov        rsi, qword [0x100143648]                    ; argument "selector" for method _objc_msgSend, @selector(indicatorView)
0000000100026527         mov        r15, qword [_objc_msgSend_1000f4360]        ; _objc_msgSend_1000f4360
000000010002652e         call       r15                                         ; Jumps to 0x100174ec0 (_objc_msgSend), _objc_msgSend
0000000100026531         mov        rdi, rax                                    ; argument "instance" for method imp___stubs__objc_retainAutoreleasedReturnValue
0000000100026534         call       imp___stubs__objc_retainAutoreleasedReturnValue ; objc_retainAutoreleasedReturnValue
// calls setHidden, not important
0000000100026539         mov        rbx, rax
000000010002653c         mov        rsi, qword [0x100142210]                    ; argument "selector" for method _objc_msgSend, @selector(setHidden:)
0000000100026543         mov        rdi, rax                                    ; argument "instance" for method _objc_msgSend
0000000100026546         xor        edx, edx
0000000100026548         call       r15                                         ; Jumps to 0x100174ec0 (_objc_msgSend), _objc_msgSend
000000010002654b         mov        rdi, rbx                                    ; argument "instance" for method _objc_release
000000010002654e         call       qword [_objc_release_1000f4368]             ; _objc_release, _objc_release_1000f4368,_objc_release
0000000100026554         lea        rdi, qword [rbp+var_30]                     ; argument "addr" for method imp___stubs__objc_initWeak
0000000100026558         mov        rsi, r14                                    ; argument "value" for method imp___stubs__objc_initWeak
000000010002655b         call       imp___stubs__objc_initWeak                  ; objc_initWeak
// important: the SiShiPurchaseHelper class
0000000100026560         mov        rdi, qword [objc_cls_ref_SiShiPurchaseHelper] ; argument "instance" for method _objc_msgSend, objc_cls_ref_SiShiPurchaseHelper
0000000100026567         mov        rsi, qword [0x100141dd8]                    ; argument "selector" for method _objc_msgSend, @selector(sharedInstance)
000000010002656e         call       r15                                         ; End of try block started at 0x10002650c, Begin of try block (catch block at 0x10002664c), Jumps to 0x100174ec0 (_objc_msgSend), _objc_msgSend
0000000100026571         mov        rdi, rax                                    ; End of try block started at 0x10002656e, Begin of try block, argument "instance" for method imp___stubs__objc_retainAutoreleasedReturnValue
0000000100026574         call       imp___stubs__objc_retainAutoreleasedReturnValue ; objc_retainAutoreleasedReturnValue
0000000100026579         mov        r15, rax
000000010002657c         mov        rax, qword [__NSConcreteStackBlock_1000f41b8] ; __NSConcreteStackBlock_1000f41b8
0000000100026583         lea        r14, qword [rbp+var_60]
0000000100026587         mov        qword [r14-0x20], rax
000000010002658b         mov        r13d, 0xc2000000
0000000100026591         mov        qword [r14-0x18], r13
0000000100026595         lea        rax, qword [sub_100026660]                  ; sub_100026660
000000010002659c         mov        qword [r14-0x10], rax
00000001000265a0         lea        rax, qword [0x1000f4e28]                    ; 0x1000f4e28
00000001000265a7         mov        qword [r14-8], rax
00000001000265ab         lea        r12, qword [rbp+var_30]
00000001000265af         mov        rdi, r14                                    ; argument "dest" for method imp___stubs__objc_copyWeak
00000001000265b2         mov        rsi, r12                                    ; argument "src" for method imp___stubs__objc_copyWeak
00000001000265b5         call       imp___stubs__objc_copyWeak                  ; objc_copyWeak
00000001000265ba         lea        rbx, qword [rbp+var_38]
00000001000265be         mov        rax, qword [__NSConcreteStackBlock_1000f41b8] ; __NSConcreteStackBlock_1000f41b8
00000001000265c5         mov        qword [rbx-0x20], rax
00000001000265c9         mov        qword [rbx-0x18], r13
00000001000265cd         lea        rax, qword [sub_100026691]                  ; sub_100026691
00000001000265d4         mov        qword [rbx-0x10], rax
00000001000265d8         lea        rax, qword [0x1000f4ef8]                    ; 0x1000f4ef8
00000001000265df         mov        qword [rbx-8], rax
00000001000265e3         mov        rdi, rbx                                    ; argument "dest" for method imp___stubs__objc_copyWeak
00000001000265e6         mov        rsi, r12                                    ; argument "src" for method imp___stubs__objc_copyWeak
00000001000265e9         call       imp___stubs__objc_copyWeak                  ; objc_copyWeak
// the important part is startRestore
00000001000265ee         mov        rsi, qword [0x100143720]                    ; argument "selector" for method _objc_msgSend, @selector(startRestore:failedBlock:)
00000001000265f5         lea        rdx, qword [rbp+var_80]                     ; End of try block started at 0x100026571, Begin of try block (catch block at 0x100026637)
00000001000265f9         lea        rcx, qword [rbp+var_58]
00000001000265fd         mov        rdi, r15                                    ; argument "instance" for method _objc_msgSend
0000000100026600         call       qword [_objc_msgSend_1000f4360]             ; _objc_msgSend, _objc_msgSend_1000f4360,_objc_msgSend
0000000100026606         mov        rdi, r15                                    ; End of try block started at 0x1000265f5, Begin of try block, argument "instance" for method _objc_release
0000000100026609         call       qword [_objc_release_1000f4368]             ; _objc_release, _objc_release_1000f4368,_objc_release
000000010002660f         mov        rdi, rbx                                    ; argument "instance" for method imp___stubs__objc_destroyWeak
0000000100026612         call       imp___stubs__objc_destroyWeak               ; objc_destroyWeak
0000000100026617         mov        rdi, r14                                    ; argument "instance" for method imp___stubs__objc_destroyWeak
000000010002661a         call       imp___stubs__objc_destroyWeak               ; objc_destroyWeak
000000010002661f         lea        rdi, qword [rbp+var_30]                     ; argument "instance" for method imp___stubs__objc_destroyWeak
0000000100026623         call       imp___stubs__objc_destroyWeak               ; objc_destroyWeak
0000000100026628         add        rsp, 0x58
000000010002662c         pop        rbx
000000010002662d         pop        r12
000000010002662f         pop        r13
0000000100026631         pop        r14
0000000100026633         pop        r15
0000000100026635         pop        rbp
0000000100026636         ret

From the assembly, the interesting part is SiShiPurchaseHelper:startRestore. Without further ado, jump into it — the raw assembly is hard to read, so let Hopper generate pseudocode:

Much clearer:

/* @class SiShiPurchaseHelper */
-(void)startRestore:(void *)arg2 failedBlock:(void *)arg3 {
    r12 = [arg3 retain];
    rbx = [arg2 retain];
    [self setStartPurchase:0x1];
    [self setCompeletedBlock:rbx];
    [rbx release];
    [self setFailedBlock:r12];
    [r12 release];
    rax = [SKPaymentQueue defaultQueue];
    rax = [rax retain];
    [rax restoreCompletedTransactions];
    [rax release];
    return;
}

Can’t follow it further from here. Looking it up, SKPaymentQueue is Apple’s macOS StoreKit library for in-app purchases — so there must be a Delegate callback that handles purchase validation. How to find it?

Back in the generated header folder, run ls *SK* to list headers whose names contain “SK”:

OSSPlainTextAKSKPairCredentialProvider.h
SKPaymentTransactionObserver-Protocol.h
SKProductsRequestDelegate-Protocol.h
SKRequestDelegate-Protocol.h

SKPaymentTransactionObserver-Protocol.h is clearly the protocol we want:

#import "NSObject-Protocol.h"

@class NSArray, NSError, SKPayment, SKPaymentQueue, SKProduct;

@protocol SKPaymentTransactionObserver- (void)paymentQueue:(SKPaymentQueue *)arg1 updatedTransactions:(NSArray *)arg2;

@optional
- (void)paymentQueue:(SKPaymentQueue *)arg1 didRevokeEntitlementsForProductIdentifiers:(
- (void)paymentQueueDidChangeStorefront:(SKPaymentQueue *)arg1;
- (BOOL)paymentQueue:(SKPaymentQueue *)arg1 shouldAddStorePayment:(SKPayment *)arg2 forP
- (void)paymentQueue:(SKPaymentQueue *)arg1 updatedDownloads:(NSArray *)arg2;
- (void)paymentQueueRestoreCompletedTransactionsFinished:(SKPaymentQueue *)arg1;
- (void)paymentQueue:(SKPaymentQueue *)arg1 restoreCompletedTransactionsFailedWithError:
- (void)paymentQueue:(SKPaymentQueue *)arg1 removedTransactions:(NSArray *)arg2;
@end

It implements several callbacks — never mind those. Search Hopper for paymentQueue and generate pseudocode:

/* @class SiShiPurchaseHelper */
-(void)paymentQueue:(void *)arg2 updatedTransactions:(void *)arg3 {
    rbx = self;
    rax = [arg3 retain];
    var_150 = intrinsic_movaps(var_150, 0x0);
    *(int128_t *)(&var_150 + 0x10) = intrinsic_movaps(*(int128_t *)(&var_150 + 0x10), 0x0);
    *(int128_t *)(&var_150 + 0x20) = intrinsic_movaps(*(int128_t *)(&var_150 + 0x20), 0x0);
    *(int128_t *)(&var_150 + 0x30) = intrinsic_movaps(*(int128_t *)(&var_150 + 0x30), 0x0);
    var_B8 = rax;
    rax = [rax countByEnumeratingWithState:&var_150 objects:&var_B0 count:0x10];
    var_D8 = rax;
    if (rax != 0x0) {
            var_100 = **(&var_150 + 0x10);
            var_C0 = rbx;
            do {
                    r12 = 0x0;
                    do {
                            if (*var_140 != var_100) {
                                    objc_enumerationMutation(var_B8);
                            }
                            r14 = *(var_148 + r12 * 0x8);
                            rax = [r14 transactionState];
                            if (rax != 0x3) {
                                    if (rax != 0x2) {
                                            if (rax == 0x1) {
                                                    // key spot
                                                    [rbx completeTransaction:r14];
                                            }
                                    }
                                    else {
                                            rax = [r14 error];
                                            rax = [rax retain];
                                            r14 = [rax code];
                                            [rax release];
                                            if (r14 == 0x2) {
                                                    rbx = var_C0;
                                                    [rbx purchaseFailedWithError:0x0];
                                            }
                                            else {
                                                    rbx = var_C0;
                                                    [rbx purchaseFailedWithError:[[[[NSBundle mainBundle] retain] localizedStringForKey:@"Unlock failed" value:@"" table:0x0] retain]];
                                                    [rax release];
                                                    [rax release];
                                            }
                                    }
                            }
                            else {
                                    [rbx completeTransaction:r14];
                            }
                            r12 = r12 + 0x1;
                    } while (r12 < var_D8);
                    rax = [var_B8 countByEnumeratingWithState:&var_150 objects:&var_B0 count:0x10];
                    var_D8 = rax;
            } while (rax != 0x0);
    }
    var_30 = **___stack_chk_guard;
    [var_B8 release];
    if (**___stack_chk_guard != var_30) {
            __stack_chk_fail();
    }
    return;
}

Now search Hopper for completeTransaction and generate its pseudocode:

/* @class SiShiPurchaseHelper */
-(void)completeTransaction:(void *)arg2 {
    r14 = self;
    rax = [arg2 retain];
    r15 = rax;
    rax = [rax payment];
    rax = [rax retain];
    r12 = rax;
    rax = [rax productIdentifier];
    rax = [rax retain];
    [rax release];
    [r12 release];
    if (rax != 0x0) {
            [r14 setCurrentTransaction:r15];
            // key methods:
            [r14 purchaseSuccess];
            [r14 bornWenYuShan];
    }
    [r15 release];
    return;
}

Two suspect key methods:

[r14 purchaseSuccess];
[r14 bornWenYuShan];

Reading further, the method that ultimately sets the user identity is bornWenYuShan:

/* @class SiShiPurchaseHelper */
-(void)bornWenYuShan {
    [self setIsVip:0x1];
    rax = [NSUserDefaults standardUserDefaults];
    rax = [rax retain];
    [rax setBool:0x1 forKey:@"kSiShiIsVipString"];
    [rax release];
    rbx = [[NSNotificationCenter defaultCenter] retain];
    [rbx postNotificationName:*0x1000f52b0 object:0x0];
    [rbx release];
    return;
}

Anyone who’s done Apple development knows [NSUserDefaults standardUserDefaults] is the user-info storage API. [rax setBool:0x1 forKey:@"kSiShiIsVipString"] writes YES. So if we can write this value and restart the app, the paywall should be bypassed.

Let’s rethink: to actually reach completeTransaction we’d have to patch several if branches — tedious. Is there a simpler way?

Since we already found the key function [SiShiPurchaseHelper bornWenYuShan], just call it directly when the “Restore Purchase” button is clicked. That skips all the intermediate patches and is easy to verify.

Back at [SiShiPurchaseHelper startRestore:failedBlock:], keep only the prologue and epilogue:

0000000100012910         push       rbp                                         ; Objective C Implementation defined at 0x10010f4d0 (instance method), DATA XREF=0x10010f4d0
0000000100012911         mov        rbp, rsp
0000000100012914         push       r15
0000000100012916         push       r14
0000000100012918         push       r13
000000010001291a         push       r12
000000010001291c         push       rbx
000000010001291d         push       rax

...... NOP out everything in between

00000001000129bd         pop        rbx
00000001000129be         pop        r12
00000001000129c0         pop        r13
00000001000129c2         pop        r14
00000001000129c4         pop        r15
00000001000129c6         pop        rbp
00000001000129c7         jmp        rax

After the NOP, write the payload at line nine (comments added for clarity):

// r14 = self
000000010001291e         mov        r14, rdi
// rbx = _objc_msgSend_1000f4360
0000000100012921         mov        rbx, qword [_objc_msgSend_1000f4360]    
// first arg for objc_msgSend: rsi = bornWenYuShan    
0000000100012928         mov        rsi, qword [0x100142d48]    
// second arg: rdi = r14                
000000010001292f         mov        rdi, r14                                    
0000000100012932         call       rbx   
// i.e. msgSend(self, bornWenYuShan)

The final patched function:

Have Hopper generate a new binary and overwrite the original at /Applications/Live Wallpaper.app/Contents/MacOS/Live Wallpaper.

Quit the app and… it won’t launch. Did I patch it wrong? No — all Apple software must be signed, and the exported binary needs re-signing. cd into /Applications/ and run:

sudo codesign --sign - --force --deep ./Live Wallpaper.app

Google for the details on code signing if you need them.

Result: on first launch, click “Restore Purchase” in the purchase window, restart the app, and the purchase window is gone — pick any asset and it downloads directly.

Developers work hard — support legit copies.


Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).

Get in touch