Investigating some subscription scam iOS apps

Investigating some subscription scam iOS apps

For some reason Apple allows "subscription scam" apps on the App Store. These are apps that are free to download and then ask you to subscribe right on launch. It's called the freemium business model, except these apps ask you to subscribe for "X" feature(s) immediately when you launch them, and keep doing so, annoyingly, over and over until you finally subscribe. By subscribing you get a number of "free days" (trial) and then they charge you weekly/monthly/yearly for very basic features like scanning QR Codes.

I've been trying to monitor apps that have these characteristics:
- They have In-App purchases for their subscriptions.
- They have bad reviews, specially with words like "scam" or "fraud".
- Their "good" reviews are generic, potentially bot-generated.

This weekend I focused on 5 apps from 2 different developers and to my surprise they are very similar, not only their UI/UX but also their code is shared and their patterns are absolutely the same. A side from being classic subscription scam apps, I wanted to examine how they work internally and how they communicate with their servers and what type of information are they sending.

"Encrypted" Analytics

The first thing I did, after installing them, was to get Burp and proxy their traffic. On app launch one of the apps made multiple requests to the domain goupdate.3g.cn, here's a screenshot:

  1. As you can see the requests are made over HTTP, meaning plaintext.
  2. All the requests have a base64 encoded body.

But a quick base64 decoding operation showed that the data was not a plaintext payload.

Then I decrypted the applications, transferred them to my computer, (shameless plug: I used my script to do so), and opened their binaries in Hopper and started reversing. After about half an hour of reversing I thought I had enough information to create a "decryption" script. These are the two methods (in pseudo-code) that have the functionality:

+(void *)xorWithBytesAryKey:(void *)arg2 key:(void *)arg3 {
    r0 = objc_retainAutorelease(arg2);
    r21 = r0;
    r20 = [r0 bytes];
    r0 = objc_retainAutorelease(r19);
    r19 = r0;
    r22 = [r0 bytes];
    r23 = @selector(length);
    r21 = objc_msgSend(r21, r23);
    [r24 release];
    if (objc_msgSend(r19, r23) != 0x0) {
            r25 = 0x0;
            do {
                    objc_msgSend(@class(NcsStDataEncryptor), @selector(xorWithSingleByteKey:len:key:));
                    r25 = r25 + 0x1;
            } while (objc_msgSend(r19, r23) > r25);
    }
    r20 = [[NSData dataWithBytes:r20 length:r21] retain];
    [r19 release];
    r0 = [r20 autorelease];
    return r0;
}

+(char *)xorWithSingleByteKey:(char *)arg2 len:(long long)arg3 key:(unsigned char)arg4 {
    r4 = arg4;
    r3 = arg3;
    r0 = arg2;
    if (r3 >= 0x1) {
            r8 = r0;
            do {
                    *(int8_t *)r8 = *(int8_t *)r8 ^ r4;
                    r8 = r8 + 0x1;
                    r3 = r3 - 0x1;
            } while (r3 != 0x0);
    }
    return r0;
}

Don't want to make you figure it out, although it might be a good exercise for you.
tl;dr The first method xorWithBytesAryKey:key: iterates over an array of characters and for each character it calls the second method xorWithSingleByteKey:len:key which then xors that character with all the characters from the key. With only 21 lines of python I was able to recreate the steps:

import base64

encoded_array = [
"JSQgMSNXMSNXISElVSZWJCc5UiIgUDkgUSEtOSwnUCU5I1dQUC0iIyIsUlAhMSNXMSNXUSQsJiJXVS05JSAmVzkgUSchOVVQUiQ5UlFSIFEjJS1QUiwmMSNXMSNXICVSIFEgLCMhJixXIFUtIVYhVldSIC0iIlctICcmViQxI1cxI1cmJCUtOSQiOSYgPyQjMSdVJiQxJ1UhLTEjVzEjV1dVMSNXMSNXISQkMSNXMSNXICIxI1cxI1clOic6IDEjVzEjVyAlUiBRICwjOSEmLFc5IFUtITlWIVZXOVIgLSIiVy0gJyZWJDEjVzEjVzEjVzEjVzEjVzEjVzEjVzEjVzEjVzEjVyUiJCcxI1cxI1cmJyYjMSNXMSNXYCQkJEt1ZGRLYH15cTEjVzEjVyUxI1cxI1cxI1cxI1cxI1cxI1cxI1cxI1cxI1cxI1cgJVIgUSAsIzkhJixXOSBVLSE5ViFWVzlSIC0iIlctICcmViQxI1cxI1cl",
]

key = 'lvsiqiaoil611230' # hard-coded key

def xor_char(c):
	x = ord(c)
	for i in key:
		x = x ^ ord(i)
	return x

for encoded in encoded_array:
	ciphertext = base64.b64decode(encoded)
	plaintext = ''
	for c in ciphertext:
		plaintext += chr(xor_char(c))
	print plaintext.replace('%7C','|').replace('%7B','{').replace('%3D','=').replace('%22','"').replace('%3B',';').replace('%7D','}').replace('%3A',':')
	print '\n'

Yes, each app has a hard-coded xor key, but all of them use the same key. After running the script on the first request's body I got this:

I then realized all these requests are analytics requests. This means these apps are sending all the information from the devices like getIPAddress, getiOSVersion, getDeviceName and getCurrentMoblileCountryCode, among other fields in the clear. The developers might call it "encrypted" body but in reality, as my script shows, it's as good as plaintext data. These are all the fields of the CSStatisticsDeviceInfo model:

I then saw all of these apps embed many ad libraries but what I found really weird is that they don't show any ads. My suspicion is that these apps have some kind of adware capabilities. I haven't been able to get the apps to show any ads, I'll update this post when/if I do.

Recipt Verification call made from client

The apps have the freemium scheme and their "products" are In-App Purchases (IAP) subscriptions. Since they have IAP subscriptions, they have to verify IAP receipts. Apple explains here how to do it and they are very clear about one thing:

**Important**: Do not call the App Store server /verifyReceipt endpoint from your app

Yes, I think you know where I'm going with this. They are performing the verification call directly from the application and yes, this means they are hard-coding the password field required by the /verifyReceipt endpoint:

+(void)checkReceiptFromAppStoreWithURL:(void *)arg2 success:(void *)arg3 failure:(void *)arg4 {
    r0 = [NSBundle mainBundle];
    r0 = [r0 retain];
    r24 = [[r0 appStoreReceiptURL] retain];
    r0 = objc_msgSend(@class(NSData), @selector(dataWithContentsOfURL:));
    if (r22 != 0x0) {
            r23 = [[r22 base64EncodedStringWithOptions:0x20] retain];
            r24 = [[NSMutableURLRequest requestWithURL:r19 cachePolicy:0x0 timeoutInterval:r4] retain];
            r0 = [ZOAppConfigManager sharedInstance];
            r0 = [r0 retain];
            r26 = r0;
            r0 = [r0 receiptPassword]; // hard-coded password
            r0 = [r0 retain];
            r25 = [[NSString stringWithFormat:@"{\"receipt-data\" : \"%@\",\"password\":\"%@\"}"] retain];
            [r0 release];
            [r26 release];
            r26 = [[r25 dataUsingEncoding:0x4] retain];
            [r24 setHTTPBody:r26];
            [r24 setHTTPMethod:@"POST"];
            r27 = [[NSURLSession sharedSession] retain];
            r0 = [r27 dataTaskWithRequest:r24 completionHandler:&var_80];
            [r28 resume];
    }
    else {
            r23 = [[NSError errorWithDomain:0x10066be48 code:0x1869f userInfo:0x0] retain];
            (*(r21 + 0x10))(r21, r23);
    }
}

The password value is hard-coded either within the binary or in a configuration .plist file.

Update: As pointed out by Andrew, wanted to clarify that verifying a receipt directly from the client app posses no security or privacy issues for the user. This is a bad practice and can harm the developer(s) not the end users.

DES encryption

Out of the 5 apps only 1 was written in Swift. This made it more fun to try to reverse its encryption methods. Starting with the hard-coded encryption key. The 4 Objc apps had their encryption keys embedded as a string literal. The Swift app was a bit more robust. This is the assembly for loading the key:

movz       x0, #0x5162
movk       x0, #0x67, 	lsl #16
movz       x1, #0x7745
movk       x1, #0x4554, lsl #16
movk       x1, #0x6e6f, lsl #32
movk       x1, #0x337a, lsl #48
bl         imp___stubs__$SSS10FoundationE19_bridgeToObjectiveCSo8NSStringCyF

In the ARM documentation we can see that movk's description is:

Move 16-bit immediate into register, keeping other bits unchanged.

It basically says that on every call a new value will be loaded to the left of the register leaving the right most bits unchanged. For example for x0:

movz       x0, #0x5162              # x0 = 0x5162
movk       x0, #0x67, 	lsl #16     # x0 = 0x675162

Following the same operations for x1 we end up with these two values on each register:

x0 = 0x675162
x1 = 0x337a6e6f45547745

Since this is a Little-Endian architecture we need to flip the bytes:

x1 = 0x457754456f6e7a33
x0 = 0x675162

After converting these hex values to ascii we get the string: EwTEonz3bQg (which is the DES encryption/decryption key). After I had the encryption key it was just matter of reverversing their encryption, which was very straightforward. This time I wrote the script in Objc:

NSString * const kDES_Key = @"EwTEonz3bQg="; // Added '=' because iOS requires base64 encoded strings to be padded.

NSData * decrypt(NSData *data, NSString *desKey) {
  NSData *key = [[NSData alloc] initWithBase64EncodedString:desKey options:NSDataBase64DecodingIgnoreUnknownCharacters];
  NSLog(@"[+] Key length %ld", key.length);
  
  size_t outLength;
  NSMutableData *plaintext = [NSMutableData dataWithLength:data.length];

  CCCryptorStatus result = CCCrypt(/* op= */ kCCDecrypt,
				                   /* alg= */ kCCAlgorithmDES,
				                   /* opt= */ 3, // wtf there's no opt=3 ¯\_(ツ)_/¯
				                   key.bytes,
				                   key.length,
				                   /* iv= */ NULL,
				                   data.bytes,
				                   data.length,
				                   plaintext.mutableBytes,
				                   plaintext.length,
				                   &outLength);

  if (result == kCCSuccess) {
    if (plaintext.length != outLength) {
      NSLog(@"[+] Buffer length %ld, truncating to %ld", plaintext.length, outLength);
      plaintext.length = outLength;
    }
  } else {
  	NSLog(@"[-] Decryption failed.");
    return nil;
  }
  
  return plaintext;
}

void printUsage() {
	NSLog(@"Usage: decryptor <base64_encoded_ciphertext>\n");
}

int main(int argc, char **argv) {
	id pool = [NSAutoreleasePool new];
	if (argc != 2) {
		printUsage();
		exit(EXIT_FAILURE);
	}

	const char *argument = argv[1];

	NSString *ciphertext = [NSString stringWithUTF8String:argument];
  NSData *decodedCiphertext = [[NSData alloc] initWithBase64EncodedString:ciphertext options:NSDataBase64DecodingIgnoreUnknownCharacters];
  NSInteger reminder = decodedCiphertext.length%8 != 0;
  if (reminder) {
    NSLog(@"[+] Ciphertext length (%ld) not multiple of 8, adding %ld bytes", decodedCiphertext.length, reminder);
    NSMutableData *mutableData = [decodedCiphertext mutableCopy];
    [mutableData increaseLengthBy:reminder];
    decodedCiphertext = [mutableData copy];
  }

  NSLog(@"[+] Ciphertext length %ld", decodedCiphertext.length);
	NSData *plaintextData = decrypt(decodedCiphertext, kDES_Key);
	NSString *plaintext = [[NSString alloc] initWithData:plaintextData encoding:NSASCIIStringEncoding];
	NSLog(@"[+] Plaintext: \n%@", plaintext);

	[pool drain];
}

Payment SDK

Another aspect that caught my attention was that all 5 apps have a flavour of a Payment SDK method like the following:

-(void)initPaySDK {
    r19 = [ZODeviceInfo isDebugMode];
    r0 = [PayNotificationConfig sharedManger];
    r0 = [r0 retain];
    [r0 initPayConfigWith:r19 withClientID:<alphanumeric_string> withSignatureKey:<alphanumeric_string> withDesKey:<alphanumeric_string>];
    [r0 release];
}

As we can see on Apple's App Store Guidelines, IAPs don't need us to setup a separate payment gateway. In fact, not only we don't need to, we can't do so:

**3.1.3(a) “Reader” Apps:** Apps may allow a user to access previously purchased content or content subscriptions (specifically: magazines, newspapers, books, audio, music, video, access to professional databases, VoIP, cloud storage, and approved services such as classroom management apps), provided that you agree not to directly or indirectly target iOS users to use a purchasing method other than in-app purchase, and your general communications about other purchasing methods are not designed to discourage use of in-app purchase.

All we have to do is to use Apple's StoreKit framework.

This made me extremely curious about why do these apps need a payment SDK. Initially I thought it was a workaround on iOS' IAPs. However, after a lot of digging around and dynamic testing where I forced the app make network calls, I realized this is their way to check for the subscriptions and they use their DES encryption methods to encrypt the payloads sent to their server.

Conclusions

  • As far as i can tell, these apps were not serving adware or doing something fishy. Don't get me wrong, they are still a rip-off, because nobody should pay $10/mo for a QRCode reader. Specially when the app changes its price from $8.99 to $9.99 on an update, guess it's to correct the price for scanning QRCodes inflation.
  • I don't understand why Apple allows such apps on the App Store.
  • The apps were not "hiding" something, they just have very, very bad practices.
  • Even though I didn't find anything fishy, it was super fun to reverse engineer these apps and practice some assembly.