بِسْمِ اللَّـهِ الرَّحْمَـٰنِ الرَّحِيم
TL;DR
Tested the raw socket layer of a pre-production POS system. Found 4 critical/high vulnerabilities — including a replay attack, cross-merchant IDOR, ghost transactions, and card identity bypass — all by speaking directly to the ISO 8583 socket.
Intro
I was doing a security assessment on a APK running on an Android-based POS terminal. The kind of terminal you see at shops and restaurants — tap your card, transaction goes through, receipt prints. Pretty normal stuff.
One important thing to mention upfront: this wasn’t a live production system. The application was still in its testing phase — not yet connected to a real bank. The backend team had a mock server running that simulated the payment processor’s responses, so all the transactions I tested were going against that mock environment. No real cards, no real money, no real bank on the other end.
As part of the engagement scope, I had access to the merchant dashboard and admin panel — which allowed me to verify findings directly: confirming whether transactions appeared in logs, whether ghost transactions were invisible, and whether voided amounts were actually reversed.
Most people approach POS security from two angles: the mobile app — reversing the APK, Search for Android Vulns — or the backend API — Hunting for Api vulns. Both are valid. But there’s a third layer that usually gets skipped: the raw socket connection between the terminal and the payment processor. That’s where I focused.
Reversing the App
Since the POS terminal was running on an Android-based OS, the application itself was a standard APK. My first step was to decompile the application to understand its architecture.
During static analysis, I discovered that the application communicated with two distinct hosts:
https://back.example.comsocket.example.com:5001
I started checking standard Android vulnerabilities, and then intercepted the API requests and tested it. However, I noticed that no API requests appeared during payments, refunds, or voids. The only APIs showing up were for request history, login, and other non-payment actions.
So, I went back to the application source code and decided to focus entirely on socket.example.com:5001 to understand how it operates and how payments are processed in this POS.
Through some reversing, I discovered that the application opens a raw TLS socket with this hostname and sends payment data directly over it using the ISO 8583 protocol, sending payment data directly over it. No API gateway, no abstraction layer. Just a socket connection to the processor. And the hostname was sitting right there in the config.
ISO 8583 Protocol
ISO 8583 is the international standard for financial transaction messages — the protocol that payment terminals, processors, and banks use to talk to each other. Every time you tap a card, a structured ISO 8583 message travels from the terminal to the processor, and back.
Every transaction is a structured message with a bitmap that tells you which fields are present, followed by the actual field data.
Understanding the ISO 8583 Hex Structure
An ISO 8583 consists of three main structural parts:
- Header and Message Type Identifier (MTI)
- Bitmaps
- Data Elements (Fields).
MTI (Message Type Identifier)
A 4-digit code that defines what kind of message it is:
| MTI | Meaning |
|---|---|
1200 | Financial Request (payment) |
1210 | Financial Response |
1420 | Reversal Advice (void) |
0800 | Network Management Request |
Bitmap
8 bytes (64 bits) where each bit represents whether a specific data field is present in the message. If bit 39 is set, Field 39 (Response Code) is in the message.
Data Fields (DEs)
the actual content. Each field has a fixed number:
| Field | Name | Example |
|---|---|---|
| DE2 | PAN (Card Number) | 5264399999999999 |
| DE4 | Transaction Amount | 000000004747 (= 47.47 EGP) |
| DE11 | STAN | 000301 |
| DE37 | RRN | 301430000001 |
| DE39 | Response Code | 00 = Approved |
| DE41 | Terminal ID | 40000001 |
| DE42 | Merchant ID | 100001 |
| DE55 | EMV / ICC Data | Raw chip cryptogram |
Response Code (DE39) is what determines the outcome.
00= Approved. Everything else is some form of decline or error.
Example of request raw hex:
3036323849534f3830313030303030600000000131323030fef76d2188e1ea1c000000000000001116353236343339393939393939393939393934343434300000000000003434343430000000000000343434343236303532303132323230000000000130303030303030313030303337393236303532303135323233383238313032363035323030353230363031313831383831383037323230303631313030303030303030303030313130303030303030303030303236303532303433393139353030303030303030303030303030303030313030303031313044756d6d795061793035335032353030313250333130303430303030303631303032303050393530303230324d504e3031343532363433392a2a2a2a35363238383138383138383138303030303030303030303030303030303138349f2608067eb1661b8ac0f09f2701809f10120114a04001240000000000000000000000ff9f3704e819d1d79f36020070950500008000019a032605209c01209f02060000000044445f2a020818820219809f1a0281009f03009f3303e002e89f34033f00019f3501229f1e0830303030303930358407a00000000410109f090200029f6e07081800003230005a08526439996948562857135264399969485628d28102211000041300000f9f6b005f24032810319b02e800303033303230303038303339303032202030373646303130303141463032303135303030333739202020202020202020463033303034303030304630343030314e4630353030323030463036303031204630373031303030303030322020202031313030303030303030303030Length Prefix: 0628 (604 bytes)
Protocol: ISO
MTI: 1200 — Financial Request
Bitmap: FEF76D2188E1EA1C
| Field | Name | Value |
|---|---|---|
| DE2 | PAN (Card Number) | 5264399999999999 |
| DE3 | Processing Code | 000000 (Purchase) |
| DE4 | Amount | 000000004747 (= 47.47 EGP) |
| DE11 | STAN | 000000 |
| DE12 | Local Time | 100037 |
| DE15 | Settlement Date | 5201 |
| DE18 | MCC | 8281 |
| DE22 | POS Entry Mode | 005 (Chip) |
| DE24 | NII | 206 |
| DE32 | Acquirer Inst ID | 81881807220 |
| DE33 | Forwarding Inst ID | 110000 |
| DE37 | RRN | 000000011000 |
| DE41 | Terminal ID | 00000000 |
| DE42 | Merchant ID | 110110111111000 |
| DE43 | Merchant Name | Dummy053... |
| DE55 | EMV/ICC Data | Binary — present |
To make things easier for myself, I make the script decode the response so I could see the response code immediately.
Example of Response
3033323849534f3830313030303030600000000131323130fef76d2188e1ea1c0000000000000011163532363433393939393939393939393939343434343000000000000034343434300000000000003434343432363035323031323232300000000001303030303030303130303033373932363035323031353232333832383130323630353230303532303630313138313838313830373232303036313130303030303030303030303131303030303030303030303032363035323034333931393530303030303030303030303030303030303130303030313130313233343536333033309f2701809f36020070Length Prefix: 0328 (328 bytes)
Protocol: ISO
MTI: 1210 — Financial Response
Bitmap: FEF76D2188E1EA1C
Total bytes: 239
| Field | Name | Value |
|---|---|---|
| DE3 | Processing Code | 000000 (Purchase) |
| DE9 | Conv Rate Settlement | 60520122 |
| DE10 | Conv Rate Cardholder | 20000001 0 |
| DE11 | STAN | 000000 |
| DE12 | Local Time | 100037 |
| DE15 | Settlement Date | 5201 |
| DE16 | Conversion Date | 5223 |
| DE18 | MCC | 8281 |
| DE19 | Acquiring Country | 026 |
| DE21 | Forwarding Country | 052 |
| DE22 | POS Entry Mode | 005 (Chip) |
| DE24 | NII | 206 |
| DE27 | Auth ID Resp Len | 0 |
| DE32 | Acquirer Inst ID | 81881807220 |
| DE33 | Forwarding Inst ID | 110000 |
| DE37 | RRN | 000000011000 |
| DE38 | Auth Code | 123456 |
| DE39 | Response Code | 3030 — 00 Approved |
| DE41 | Terminal ID | 00000000 |
| DE55 | EMV/ICC Data | 9F2701809F36020070 |
More info about ISO 8583:
Getting Socket Access
Getting the raw frames
The socket runs over raw TLS — Burp Suite can’t intercept it because there’s no HTTP proxy protocol involved. Wireshark captures the packets but only sees encrypted bytes.
During APK analysis, a hidden flag was found: debugMode=false. I realized from apk code that when debug mode is enabled, it generates extensive logs for a lot of data. After decompiling with apktool, flipping the flag, re-signing, and reinstalling — the app printed the raw hex of every ISO 8583 frame to its logs. No interception needed. so I just grabbed the hex directly from the logs and used it as-is.
Connecting to the Socket
With help from my friend Hassan Mohsen and AI, we managed to write a small Python script that could connect directly to the socket, authenticate, and send raw ISO 8583 frames.
Step 1 — Login: A standard HTTPS POST to the login endpoint with credentials returns a JWT token. Nothing unusual here — same as any REST API login.
# POST credentials to REST endpoint → receive JWTjwt = http_login(login_url, username, password, serial)Step 2 — Open TLS socket: Open a raw TCP connection to the processor’s host and port, then wrap it in a TLS context.
# Raw TCP connection wrapped in TLS# Certificate verification disabled (same as app debug mode)sock = open_tls_socket(host, port)Step 3 — Send AUTH frame: The auth frame has a specific structure: a 4-byte length prefix, followed by the literal bytes AUTH, a 4-digit length indicator for the JWT, then the JWT itself. The server reads this, validates the token, and responds with AUTHOK.
# Frame structure: 4-byte length + "AUTH" + 4-digit JWT length + JWTjwt_b = jwt.encode("utf-8")payload = b"AUTH" + f"{len(jwt_b):04d}".encode() + jwt_bframe = f"{len(payload):04d}".encode() + payloadsock.sendall(frame)# → AUTHOKStep 4 — Send ISO 8583 frames: After AUTHOK, the socket accepts raw ISO 8583 frames. Each frame is the hex payload prefixed with a 4-byte ASCII length. The server responds with the corresponding response message.
# Each frame = 4-byte ASCII length prefix + raw ISO 8583 bytessock.sendall(data)This is the same flow the POS app uses — reconstructed from the decompiled code and the debug logs. The key insight is that once you have the auth flow and a valid payload, you can interact with the processor as if you were a legitimate terminal.
┌─────────────────────────────────────────────────────┐│ POS App │└──────────────┬──────────────────────────────────────┘ │ 1. POST /login (credentials) ▼┌─────────────────────────────────────────────────────┐│ REST API │└──────────────┬──────────────────────────────────────┘ │ 2. ← JWT token │ │ 3. Open TLS socket ▼┌─────────────────────────────────────────────────────┐│ Payment Processor Socket ││ :5001 / TLS │└──────────────┬──────────────────────────────────────┘ │ 4. → AUTH frame (JWT) │ 5. ← AUTHOK │ │ 6. → ISO 8583 frame (payment) │ 7. ← ISO 8583 response (DE39=00) ▼┌─────────────────────────────────────────────────────┐│ Bank/Issuer │└─────────────────────────────────────────────────────┘Screen from the tool
Once I could read the responses, I started working through a checklist of everything worth testing.
There are some tools you can use, but they didn’t work for me.
My Methodology
Here is what you can test during this type of assessment:
1. Access Controls
- Unauthenticated Socket Connection: Connect to the socket without sending any AUTH frame or credentials, then send a raw payment request.
- What to look for: Does the server reject the request, or process it silently?
- Cross-Merchant Void Replay: Execute a Pay transaction under Merchant A. Capture the corresponding Void hex frame. Authenticate into the socket using Merchant B’s credentials and replay Merchant A’s Void frame.
- What to look for: Does the server cross-authorize the Void, or validate that the requesting session owns the RRN?
- Parameter Tampering: Modify DE41 (Terminal ID) and DE42 (Merchant ID) in a Void or Refund payload to point to a different merchant, while authenticated as the original merchant.
- Forged / Non-Existent RRN: Send a Void or Refund using a random, forged, or non-existent RRN (e.g.
000000000000), or an RRN belonging to a different account.- What to look for: Does the server validate that the RRN exists and belongs to the current session before processing?
2. Logic Flaws
- Replay Attack: Re-submitting the exact same raw hex payload (same STAN and same RRN) within seconds. The backend failed to return the cached response, processed the request again, and debited the amount a second time, creating a double-spending flaw on the dashboard.
- What to look for: Does the server return the cached response, or does it process the request again and debit the amount a second time?
- Timed replay Attack: Captured a payment request and replayed it after waiting for the deduplication window to expire.
- What to look for: Does the server treat the request as new after the window expires?
- Card Identity Bypass on Void and Refund: Initiate a Void or Refund, and when the terminal prompts for the original card, insert a different card. Then bypass the UI entirely and send the raw hex directly over the socket with a randomized card number.
- What to look for: Does the server compare the PAN in the reversal request against the PAN on the original transaction, or only validate the RRN?
- Amount manipulation. Modified DE4 in a Refund payload to request an amount greater than the original purchase. Tested what happens when the numbers don’t add up.
- What to look for: Does the server enforce that refund amounts can’t exceed the original transaction?
- Zero and negative value fuzzing. Sent transactions with
000000000000in the amount field, and negative values, to see how the parser handled them.- What to look for: Parser errors, unexpected approvals, or state corruption at edge-case amounts.
- Cross-currency switching. Changed the currency code fields mid-transaction to see if a transaction started in one currency could be settled in another.
- What to look for: Can a transaction started in one currency be settled in another? Does the server enforce currency consistency?
- MTI confusion. Took a valid Void payload and changed the MTI to
0420(Reversal Advice) for an already-settled transaction, then watched whether the dashboard treated it as a reversal and changed the transaction state.- What to look for: Does the server re-process the transaction as a reversal? Does the dashboard change the transaction state?
3. Packet Structure and Parser Robustness
- Variable-length boundary fuzzing. Locate LLVAR or LLLVAR fields — including EMV tags like
9F26,9F37— and set the length indicator higher than the actual data (padding with zeros or garbage). Then the reverse: set the length indicator shorter than the actual data.- What to look for: Parser panics, buffer overreads, field misalignment on subsequent fields, or server crashes.
- Bitmap desynchronization. Remove a required financial field from the payload while leaving its corresponding bit set in the bitmap. Then do the reverse: clear the bit but include the field data.
- What to look for: Does the server’s field parser and bitmap parser stay synchronized, or does the inconsistency cause a misparse of subsequent fields?
The Findings
During this engagement, I focused exclusively on the socket layer — not the mobile app itself. There are separate mobile-specific vulnerabilities that are outside the scope of this checklist.
Four vulnerabilities were confirmed at the socket layer:
1. Replay Attack — Timed Window Bypass:
The server implements a short in-memory deduplication window. Within that window, sending the same frame twice returns the cached response. But once the window expires, the server has no memory of the original request — and approves the replay as a brand new transaction.
I captured a payment request, waited for the window to expire, and replayed the same raw hex. A new approved entry appeared on the dashboard. The card was effectively charged twice for a single transaction.
Impact: An attacker with access to captured socket traffic can re-submit any past transaction after the window expires, charging a card multiple times for the same purchase.
2. IDOR — Cross-Merchant Transactions:
The server accepted Void and Refund requests without validating that the RRN in the request actually belonged to the authenticated merchant.
I authenticated into the socket using Merchant B’s credentials — a completely different username and password — then sent a Void request referencing an RRN that belonged to a transaction made under Merchant A. The server approved it. The dashboard showed the Void logged under Merchant B’s session, against Merchant A’s original transaction.
No field tampering was needed. The RRN alone was enough — the server never checked whether the transaction behind that RRN was owned by the merchant making the request.
Impact: Merchant A can void or refund Merchant B’s transactions.
3. Unauthenticated Transaction Processing with Ghost Logging
I connected to the socket without sending any AUTH frame, then sent a raw payment request directly. The server processed the request, returned DE39 = 00, and incremented the internal transaction counter. But when I checked the admin dashboard — nothing. No entry in the transaction log, no record in the merchant history, no audit trail anywhere. The transaction existed in the system’s internal state but was completely invisible to every monitoring surface.
Impact: An attacker can process transactions that leave no trace. From a forensics perspective, if this were exploited in production, there would be no way to detect or investigate the activity after the fact.
4. Missing Cardholder Verification on Void/Refund
This one required a specific sequence:
- Performed a Pay transaction using Card A — approved, appeared on dashboard
- Initiated a Void on that transaction
- When the terminal prompted for the card, inserted Card B instead
- Terminal displayed a card mismatch error and dropped the request — it never reached the socket
- But the raw hex of the Void request was already printed in the APK debug logs
- Copied that hex and sent it directly over my own socket connection
- Server returned DE39 = 00 — Approved
Impact: anyone with POS access can reverse any transaction using any card.
The mock server’s replay vulnerability made testing easy because I could reuse the same hex baseline. However, if the server isn’t vulnerable to replays, you can still test the rest of the checklist. Simply intercept a fresh, real-time raw hex frame right before it leaves the terminal, and immediately send/modify it directly through your custom socket script to bypass the terminal UI.
A Note on the Mock Server
Someone reading this might reasonably ask: would any of this work against a real bank?
For issues like double-spending or zero-amount fuzzing, probably not—real banks enforce strict duplicate detection (RC 94) and validation rules that the mock server lacked.
However, relying on the bank to fix these bugs is an architectural mistake. The payment processor sits in the middle and is fully responsible for its own authorization layer. More importantly, the most critical flaws have nothing to do with the bank: logical checks like cross-merchant IDORs, session authentication, and matching card identities during a Void are managed entirely by the processor. The mock server didn’t create these vulnerabilities; it just made them easier to expose.
Thanks for reading and feel free to contact with me and don’t forget to follow me on Linkedin and X