I’ve had problems with IAP hackers for a few months now and just can’t get it under control. The process is as follows:
Player purchases IAP product
Game checks whether this transaction ID (from PurchaseEventArgs) has already been used:
foreach (string alreadyUsedTransactionID in storedTransactionIdsSplits)
{
if (alreadyUsedTransactionID == e.purchasedProduct.transactionID)
{
validPurchase = false;
break;
}
}
If yes: process is canceled
If no: Transaction ID is saved securely
Next, the payload from the receipt is parsed:
if (validPurchase)
{
Dictionary<string, object> wrapper = (Dictionary<string, object>)MiniJson.JsonDecode(e.purchasedProduct.receipt);
if (wrapper == null)
{
throw new InvalidReceiptDataException();
}
#if UNITY_IOS
string payload = (string)wrapper["Payload"]; // For Apple this will be the base64 encoded ASN.1 receipt
#elif UNITY_ANDROID
string payload = (string)wrapper["Payload"];
wrapper = (Dictionary<string, object>)MiniJson.JsonDecode(payload);
if(wrapper == null){
throw new InvalidReceiptDataException();
}
string inAppPurchaseData = (string)wrapper["json"]; // This is the INAPP_PURCHASE_DATA information
#endif
}
At the end, the process ALWAYS ends with the following code:
return PurchaseProcessingResult.Complete;
As soon as the user has received his goods, the payload receipt is saved and sent immediately to my server, which then validates it with Apple. This is done afterwards because I want to make sure that the player receives his goods immediately (if my server is currently not available). If Apple sends me a negative result, the player will be removed from all rankings immediately.
The only problem here is that Apple ALWAYS returns a “valid” result.
I have now jailbreaked my device for testing purposes to test this behavior. Unfortunately, I can use any IAP hack tools to bypass my security check.
I just can’t understand why Apple is giving me back a “valid” result for a fake receipt. The only thing I noticed is that in the Apple Json response the field “in_app” is empty every time (can I deduce from this that it is a cheater)?
Incidentally, these are consumable, non-consumable and subscriptions IAPs.
Can someone help me and tell me where the error in my procedure is? : /
You should return Pending not Complete, and only mark the transaction complete when your server has completed the validation. You should not return Complete if your validPurchase is false. You can then call ConfirmPendingPurchase Unity - Scripting API: Purchasing.IStoreController.ConfirmPendingPurchase Also, the Sample IAP project here allows you to toggle between returning Complete and Pending at runtime which is handy for testing this Sample IAP Project
I have now implemented what you said. I also want to add an additional check:
It is to be checked whether the product ID from the receipt matches the product ID from the “PurchaseEventArgs.purchasedProduct” argument. In addition, the transaction ID should be saved securely to prevent it from being used again for another purchase. If this transaction ID has already been used, the purchase process will be canceled. However, this process doesn’t seem to work, so no purchase works. Maybe you can check the code to see if we made a mistake? That would be nice (I removed code for safe storage with PlayerPrefs so that these things are not in a public forum.) I know that it is very difficult to find a bug without testing it yourself. But maybe we are making an ordinary mistake in the code or could there be problems testing in the sandbox?
Since we’re working with a publisher, there’s no way to test these things directly. Unfortunately, the publisher can only submit builds directly through Apple so we can test them (which of course is a fairly long process) …
public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs e)
{
PurchaseProcessingResult result;
// if any receiver consumed this purchase we return the status
bool resultProcessed = false;
bool validPurchase = false;
#if UNITY_IOS || UNITY_ANDROID
try
{
CrossPlatformValidator validator = new CrossPlatformValidator(GooglePlayTangle.Data(), AppleTangle.Data(), Application.identifier);
IPurchaseReceipt[] validatorResult = validator.Validate(e.purchasedProduct.receipt);
if (validatorResult.Length == 0 || string.IsNullOrEmpty(e.purchasedProduct.receipt)) throw new IAPSecurityException();
string storedTransactionIDs = SecurePlayerPrefs.GetString("StoredTransactionIDs", "cheater");
foreach (IPurchaseReceipt productReceipt in validatorResult)
{
// There seems to be a problem here:
AppleInAppPurchaseReceipt apple = productReceipt as AppleInAppPurchaseReceipt;
if (null != apple)
{
if (productReceipt.productID == e.purchasedProduct.definition.id &&
!IsTransactionIDAlreadyUsed(apple.transactionID, storedTransactionIDs))
{
validPurchase = true;
break;
}
}
}
}
catch (IAPSecurityException ex)
{
Debug.Log("Invalid receipt, not unlocking content. " + ex);
}
if (validPurchase)
{
foreach (IAPButton button in activeButtons)
{
if (button.productId == e.purchasedProduct.definition.id)
{
result = button.ProcessPurchase(e);
resultProcessed = true;
}
}
foreach (IAPListener listener in activeListeners)
{
result = listener.ProcessPurchase(e);
resultProcessed = true;
}
}
// we expect at least one receiver to get this message
if (!resultProcessed)
{
OnPurchaseFailed(e.purchasedProduct, PurchaseFailureReason.Unknown);
Debug.LogError("Purchase not correctly processed for product \"" +
e.purchasedProduct.definition.id +
"\". Add an active IAPButton to process this purchase, or add an IAPListener to receive any unhandled purchase events. Maybe this purchase was not valid through an invalid receipt");
return PurchaseProcessingResult.Complete;
}
LatestBuyedProducts.Add(e.purchasedProduct);
#if UNITY_IOS
return PurchaseProcessingResult.Pending;
#else
return PurchaseProcessingResult.Complete;
#endif
}
bool IsTransactionIDAlreadyUsed(string transactionID, string storedTransactionIDs)
{
string[] storedTransactionIdsSplits = storedTransactionIDs.Split(new string[] { "#/#" }, StringSplitOptions.None);
foreach (string alreadyUsedTransactionID in storedTransactionIdsSplits)
{
if (transactionID == alreadyUsedTransactionID)
{
return true;
}
}
return false;
}
You should not be checking the TransactionID as you suggest, they are generated automatically by Apple. You’ll need to test this and provide the logs from XCode. I might suggest that you test separately with your own Apple developer account to properly debug. How are you currently tracking your Debug.LogError output? Please provide the output. How To - Capturing Device Logs on iOS Also, what problem are you trying to solve? There is rarely any hacking on the iOS platform.
The transaction IDs are generated by Apple, exactly. But since these are unique, they can only appear once per item purchased. If this transaction ID occurs again in another purchase, a product will not be activated again for this receipt. That shouldn’t be a problem either, because there are always several receipts in an Apple receipt and one of these receipts (the foreach loop for that) should contain the newly purchased product, right? If the player buys 2 non-consumable products and then one consumable product, there should be 3 transaction IDs in the Apple receipt and at least the new consumable product should include a new unique transaction ID.
As I said, since we’re working with a publisher, we can’t make sandbox purchases without having the publisher sign it. Of course, we could now test with our own Apple developer account, but that would also take a lot of time.
We are currently monitoring the output with Xcode and GameAnalytics.
The reason for implementing IAP validation is fairly simple: we offer online leaderboards, clans and events where players can compete against each other. We are currently getting around 10 new hackers a day on iOS-platform, of which 8 use IAP hack tools and 2 memory injection tools. We were able to successfully prevent the memory injection, so only the IAP hackers are left and 8 a day are simply too many for games with online services.
I see you are using an IAPButton class and also scripted IAP? Can you elaborate, is that your own class, or from CodelessIAP? You would not want to mix Unity Scripted IAP and CodelessIAP. Please provide the output that you are seeing in your testing via XCode. And I trust that your IAP works as expected, except for this TransactionID check? You would want to put additional Debug.Log statements through your code, and provide the debug output.
Indeed, we use CodelessIAP. However, so far we have only supplemented the “ProcessPurchase” method with the IAP validation procedure. That should not be a problem?
We will test it with an official build signed by the publisher and post the logs here, so that we may find a solution soon.
It’s a bit annoying that a framework like LibGDX does this local validation on its own and doesn’t seem to cause any problems
Thanks for help first, we will write here as soon as there is something new
eidt: Yes, exactly, the transaction ID check works, as we already had it in use (only without CrossPlatform validator)
That is likely your issue, you should definitely not be mixing Codeless and Scripted IAP, you’ll get duplicate callbacks and unexpected behavior. If you are directly editing the Codeless scripts, they would be overwritten on the next IAP update and is not recommended. Instead please see the mentioned Sample IAP project as an example.