Thanks to @yarbabin for the logo
Electronic payment systems have existed on the Internet for a long time, and some bugs in them are twenty years old. We've found critical vulnerabilities allowing us to steal money and drive up the balance. Today we will analyze typical implementations of payment processing and related security issues.
Overview of payment systems and typical API implementations
Few people know, but the first (anonymous!) payment system was DigiCash, which appeared back in 1989, followed in 1996 by a more well-known (mainly among carders) system E-gold.
But let's go back to the present and list the main modern major payment systems/e-payment services that allow you to accept payments on your own website:
- YooMoney (former Yandex.Money)
As well as dozens of lesser-known systems with names you are not likely familiar with, not to mention the emergence of hundreds of new ones specializing in cryptocurrencies.
Despite the apparent simplicity, the payment processing, in terms of creating a secure software implementation, is a complex process that still causes problems for both large trading platforms and new electronic payment systems, which periodically enter the market with "new and convenient" APIs and other ways of integration. What does a typical payment processing look like? First, let's take a look at the current implementation described by PayPal, the so-called PayPal Express Checkout.
This implementation can be considered relatively safe, and here's why:
- Payment parameters are not transmitted explicitly, a token is used instead
- The server of the payment system does not send the results to some URL on its own, instead your website has to request them and process the response
- In general, the interaction layout is implemented in such a way that a potential developer has a minimum of opportunities to "shoot himself in the foot"
And now let's look at the diagram offered by WebMoney:
The process diagram doesn't make any sense. Also, the diagram does not reflect a number of nuances, such as request signing. Or that the URL which receives the technical payment information from the payment system and the URL where the user is redirected to (to view the payment details) should be different. The architecture used by WebMoney often emerges elsewhere, in one form or another, usually in other payment systems that have been created in the Commonwealth of Independent States.
Excessive complexity of the payment process leads to financial losses. For example, 10 years ago I published a note about the problem of integration of the Global Collect Services system with WebMoney, which allowed to confirm payments without actually paying on Steam, Battle.net and some other platforms.
What was the problem? Earlier I mentioned the URLs on the merchant side that are supposed to accept the payment information. According to the documentation, WebMoney has three entities:
- Success URL - the URL (on the merchant's website) to which the buyer's web browser will be redirected if the payment in the Web Merchant Interface service is successful. The URL can be prefixed with "http://" or "https://".
- Fail URL - the URL (on the merchant's website) to which the buyer's Internet browser will be redirected if the payment in the Web Merchant Interface service has not been completed for some reason. The URL can be prefixed with "http://" or "https://".
- Result URL - the URL (on the merchant's website) to which the Web Merchant Interface service sends an HTTP POST or SMTP notification about the payment with its complete details. The URL can be prefixed with "http://", "https://" or "mailto:".
What some developers do after reading the documentation:
- Use a single URL, which allows to figure out the address of the handler (also the handler, including the Result URL, can be displayed in the payment form on the WebMoney site, but this does not always happen and probably depends on the settings).
- Incorrectly implement signature verification for the request that comes to the Result URL. This allows the customer to substitute payment details.
- Check the signature, but don't check the amount that was sent to the Result URL. This allows you to get a $100 item by paying, for example, $0.01.
- Check the signature, the amount, but not the format of the transferred amounts. Remember, I mentioned sending payment parameters through the client's browser? WebMoney correctly handles the value of
0xFF, but the comparison of such numbers on older versions of PHP, taking into account the nuances of comparisons in the PHP language, can lead to the most unexpected consequences.
- Not exactly a payment system problem, but what about race conditions and identical internal payment IDs on the merchant's server? Hello, balance multiplication.
Signature of requests
- When paying via WebMoney, the users, in accordance with the specifications of the payment system, were redirected to the WebMoney site, where they could see the amount of payment, account number and other parameters.
- After pressing the "Next" button and authenticating in the system, information about the URL responsible for processing the payment result (Result URL) became available.
- The user could generate a request to the target URL, which, according to the WebMoney specification (well, almost), informed the payment system that the payment was successful.
- Known unified payment results handler.
- Lack of signature verification (and no signature in the request as such).
- Use of data transmitted through the user's browser as a trusted source of payment information (although, according to WebMoney specifications, this could be done through a callback coming from WebMoney servers).
All this allowed making dummy transactions and buying anything that used Global Collect processing without paying. The problem was eliminated only after ~2 weeks of wide exploitation.
Another similar problem, but a bit more complicated, was recently discovered in Smart2Pay.
Another issue related to request signature is Length Extension Attack.
According to Wikipedia, this is a type of attack on a hashing function that adds new information to the end of the original message. In this case, the new Hash value can be calculated even if the content of the original message remains unknown. You can learn more here. The problem was encountered only a couple of times when the developers decided to implement their "cool" VK-style request signing (who did not invent an algorithm themselves by the way). Below is an illustration on how to appropriately generate a signature and how to "shoot yourself in the foot."
“Result URL” disclosure
The full URL used by the payment system to notify the site if the payment is successfully credited was displayed with the parameters (including the signature) on a web site where top-up via WooPay (via SMS) was available.
The logic is simple enough; you need to figure out a way to trigger an exception so that the web application displays an error.
If you choose to pay via SMS and enter a random invalid phone number, you get:
Repeat the HTTP request a couple of hundred times. After banning our client, the web application throwed an exception. Its text contained the secret URL. By following it, we could finalize the payment.
Let's move on to the problem of checking payment attributes.
Right when you send the request, you need to substitute the payment amount in it:
There is a high probability that the payment will be successful. And then the web site either checks the amount or not. Since the payment metadata has the user ID and/or payment ID, this is enough to purchase something.
A small example:
The screenshot shows the Telegram voice assistant bot subscription, but the payment amount is not checked, allowing you to purchase it for an arbitrary price. Change
19 to get it for real cheap. This kind of vulnerability is pretty common (for example, there are many well-known bot services in Telegram which suffer from the same issue), including on popular international resources (an old example – the purchase of a Minecraft license). You can still encounter this even in 2022.
Another relevant example, but not related to the payment amount, but rather to the currency, was present in QIWI. You could top up the wallet balance by sending SMS to a short number, and the currency was transmitted through the client's browser at several stages (selecting the currency and amount, sending SMS), where the server trusted the client's data. As a result, $100 was credited to the account, for a payment of 100 rubles.
What about 2022?
Let's look at an Armenian bank chat https://t.me/Inecobank_forum/6333:
Hello! Is there any way to transfer money from a ruble account immediately into card account in drams (bypassing current bank account in drams) so as not to violate the currency legislation of the Russian Federation?
In general, before I was told that this was impossible, I made a transfer of 100,000 rubles from my current account to the card account in drams using its details. 100,000 drams have been credited. 100,000 rubles were deducted. All in INECO. Apparently there is no automatic verification of the payment currency. Now I'm dealing with support. Moral - don't do this.
This means the problem is still relevant.
Formats and types comparison
An interesting problem was discovered on Anticaptcha service, well known in certain circles (a service for solving captchas for money with API interface). User's personal account allowed performing a number of operations, including withdrawal of the unused balance to WebMoney. WebMoney handles the amount of payment in different formats quite normally (e.g.
0xFF), but comparing such numbers, especially in older versions of PHP, taking into account the nuances of comparison in PHP, having the "most perfect code", led to the most unexpected consequences. In a hexadecimal notation, the comparison of the current balance with the amount requested for withdrawal did not work correctly, which allowed the account balance to go negative.
An example of a PHP code fragment that could lead to this:
Another mistake is the peculiarities of typecasting.
NodeJS is another example of a language with dynamic typing. When adding a string to a number, it will concatenate
1+"1" = "11". However, if we subtract a number from the string, the string is converted to the number
"11"-1 = 10.
The most popular data exchange format is JSON:
It is obvious, that this JSON is correct: there is the amount parameter with the value 100. But this will also be a valid JSON:
Here, the type of the amount parameter is a string. Depending on the algorithm of JSON processing (by the way, interesting relatively related article), someone may add the value of this parameter to the number
1337, and the result will be
1337100, and not what was originally intended.
Business logic flaws
A payment is initiated in remote banking, to confirm it, you need to enter the code from the SMS. The payment is saved as uncompleted and is available for editing in the remote banking mobile app. We edit the payment, then enter the confirmation code in the browser, and the final transfer is made with one amount, but the account is debited with another.
- Open the balance top up interface (the balance is $1000, the top up amount is $100).
- The web app remembers your current balance.
- Meanwhile, we spend money (send it to the second account).
- After completing the transaction, the balance will be $1100.
What also deserves a special mention, is the shopping cart logic on resources where several currencies are supported. A vulnerability that was found in the Xbox regional store a few years ago (and has re-appeared a couple more times after the fix):
- You add the product to the shopping cart for the minimum price in rubles.
- You look for expensive games in the store, the prices of which are listed in US dollars.
- Add them to your cart.
- The store calculates the total cost of items, but items with USD prices are recalculated to RUB at a ratio of one to one.
The screenshot above shows that the cost of games in the shopping cart is indicated in rubles, but the actual amount hints that it should have been in dollars (or should be completely different after conversion at the appropriate exchange rate).
Votes on VKontakte were added by sending the paid SMS while having a near-zero balance. You send an SMS, the telecom operator can't take the money (there is no overdraft), but the votes are still replenished.
Another vector via SMS is a transfer from your account to someone else's account in the QIWI payment system. This was done by sending a message to a dedicated short number:
Transfer money to another user. Send an SMS to 7494 with the text 'perevod' or 'transfer', enter the wallet number and the amount of the transfer separated with a space. For example: perevod 9161234567 500. You will receive an SMS with a one-time code - send it back.
That short number is in fact an alias of a real phone number that is used by the SMS gateway for integration with the API. Apply a little social engineering to the support service:
Request type: other topic.
Client software version: WEB v3.0.
Message: Good afternoon! Is there an alternative to number 7494 - the "Content block" service is enabled (sending and receiving paid SMS / MMS from short numbers is prohibited, as well as calls to paid short numbers), and it is very convenient to use the number, but it's impossible because the number is short. Thank you.
The next step is to use spoofing services (to spoof Caller ID) to send an SMS from a number of an account with a lot of money. This method has never been tested by me, although in theory it looks extremely promising. Some experts say that it is possible to link a credit card via SMS (in a similar way), and then to drain money from the card.
Now let's examine the refund operation. If you consider the canonical refund process, it becomes obvious that at each stage it is possible to skip or incorrectly implement some of the checks, which will lead to financial losses.
In practice, there were web sites where the refund amount was equal to the current price of the product. Instead they should have used an actually paid amount from the relevant transaction record. Together with periodic discounts, this led to obvious results. The situation is rare, but sometimes it occurs in one form or another.
- A user converts 0.29 RUB to US dollars.
- If the value of one dollar is 60 RUB, the amount of 0.29 RUB corresponds to USD 0.0048333333333333333333333333.
- This amount will be rounded to two decimal places, i.e. to 0.01 USD (one cent).
- Then the user converts 0.01 USD back to rubles and receives 0.60 RUB.
- Thus, the user "wins" 0.31 RUB.
If you look closely, you can see the vulnerability, although it is already fixed. Overflows and bugs with negative amount transactions can be encountered periodically, even with banks from the top 100 list. Transaction with a negative amount is a trivial bug when working with signed numbers, and yes, it still happens too.
A less trivial example of overflow is the calculation of the order amount when adding a large number of items to the cart.
Another example is the compatibility of large numbers when transferring between systems. In HTTP-request, the transmitted number will be a string, but the processing of large numbers may be different, i.e. a top up request is sent for more than INT_MAX+2, on the local system, the number is processed correctly, but the payment system receives an invoice for payment of
Keep in mind that the target system may not use a 32-bit variable to store the value, but a 64-bit one.
To get a better idea, you can play with the numbers on Vkontakte. VKontakte earlier used 32-bit integers. To reach the
id1 page by overflowing the id, it was necessary to pass
Pavel Durov's page could be opened under the following URL: https://vk.com/id4294967297. But now everything has been converted to 64-bit integers, so to get
id1, you need to pass 2^64+1.
These are the same pages:
https://vk.com/id1 == https://vk.com/id18446744073709551617
Now let's consider, that in some web application, an operation with
id=100 can only be performed by an administrator. And what if this is an operation with
By the way, sometimes you can miscalculate the numbers, and go deeply negative, never getting the profit.
Let's move on to the race condition issue. According to Wikipedia, a race condition or race hazard is the condition of an electronics, software, or other system where the system's substantive behavior is dependent on the sequence or timing of other uncontrollable events. It becomes a bug when one or more of the possible behaviors is undesirable.
A conventionally canonical example:
- We perform a transaction to transfer funds from the available balance.
- We perform the same operation N times, and let's consider we should run out of balance at (N-1)th request or earlier. Sending requests with minimal delay may exploit the race condition and overcome this limitation (here HTTP-pipelining, HTTP2 features (multiple requests within one TCP session), etc. come to rescue).
- We get a negative balance, which is not permitted under normal circumstances.
The operation algorithm was as follows:
- We create a take profit of 0,1 BTC, when the price of Bitcoin is $100,000.
- The exchange withdraws (locks) 0,1 BTC from the account balance.
- We delete the take profit by sending 438695936458926734 requests.
- The exchange "returns" 0.1 × N BTC, where N is the number of simultaneous operations.
This category of problems is not specific to financial transactions. This also includes problems like TOCTOU, when, for example, the application checks the file signature, then some time passes, and then the application reads the contents of the file (which can be substituted by that time).
One of the problems was present on xss.is in the system for transferring BTC between accounts.
So, we deposit 0.1337 BTC and send a lot of transfer requests.
Implementing a secure payment processing is a complex task that should be handled by experienced developers. The resulting product needs to be comprehensively tested, otherwise we will observe childish security problems from the early noughties for decades to come, especially when new cool payment methods (hello, cryptocurrencies) and related payment systems appear. And we haven't even mentioned attacks on pseudorandom number generators, Padding Oracle, and many other fun things that deserve a separate article.
The article was originally posted on 06.22.2022 on xss.is forum.