How offline POS transaction queuing works

Nigerian POS deployments run on intermittent GPRS and Wi-Fi. Network drop-outs during a transaction are common enough that every serious terminal app implements an offline queue. The flow looks like this:

  1. Customer taps card or enters PIN. Terminal records the transaction locally — amount, card token, timestamp, merchant ID, terminal ID — in a SQLite database or shared preferences file on the Android filesystem.
  2. The transaction is marked status: PENDING_SYNC.
  3. When network connectivity is detected, the terminal POSTs a batch of pending transactions to a /sync endpoint on the acquirer backend.
  4. The backend validates the batch, credits the merchant's settlement account, and returns a list of acknowledged transaction IDs.
  5. The terminal marks those IDs as SYNCED and clears them from the local queue.

The critical assumption the backend makes: the data arriving in the sync POST accurately reflects what happened at the point of card acceptance. In most implementations we have reviewed, this assumption is not cryptographically enforced.

The local storage risk

Android POS terminals are physical devices sitting in shops, kiosks, and open-air markets across Lagos, Kano, and Port Harcourt. They can be rooted. Many budget terminals — particularly the generic Android-based models distributed through agent banking networks — ship with unlocked bootloaders or carry publicly documented root exploits for their chipset firmware.

On a rooted terminal, the /data/data/ partition is readable. The offline transaction queue for a POS app typically lives at a path like:

/data/data/com.fintech.pos/databases/transactions.db

Without full disk encryption enabled at provisioning — and many deployments skip this step — an attacker with ADB access or a physical storage extraction tool can read and write this file directly. SQLite databases are not encrypted by default. The transaction records are plaintext rows in a table.

Even with full disk encryption, if USB debugging is left enabled on a production terminal (another common gap), an attacker can use adb shell after gaining root to navigate the filesystem without decrypting anything — Android's FDE applies to at-rest protection from physical disk extraction, not from an active rooted ADB session.

The attack scenario: physical access

The end-to-end attack sequence for an attacker merchant requires no advanced tooling beyond a rooted device and a standard SQLite editor:

  1. Acquire a terminal. Legitimate merchant onboarding at most Nigerian agent networks requires minimal KYC. An attacker registers as a merchant and receives a terminal.
  2. Root the device. Budget Android POS terminals — common across tier-2 and tier-3 fintechs — frequently run Android 8 or 9 on MediaTek chipsets with known exploits. Root is achievable in under an hour using documented exploit chains.
  3. Process a real transaction offline. The attacker conducts a legitimate ₦5,000 transaction using a card they control (or any card) while the device is in flight mode. The transaction is written to the local database with amount: 5000.
  4. Modify the database record. Using a rooted file manager or ADB, open transactions.db and update the amount field: UPDATE transactions SET amount = 500000 WHERE status = 'PENDING_SYNC';
  5. Restore network and trigger sync. The terminal POSTs the tampered batch. If the backend does not validate the transaction amount against a cryptographic signature generated at card tap, it processes ₦500,000.
  6. Merchant account credited. The acquirer debits ₦500,000 from its settlement pool. The card was only charged ₦5,000 (or was never charged at all if the card was a test card controlled by the attacker).

The network-level sync attack: no physical access required

Physical access is not mandatory. If the sync endpoint communicates over HTTPS but the terminal app does not implement certificate pinning — which the majority do not, based on APK reviews — an attacker on the same network as the terminal can run a MITM proxy (Burp Suite, mitmproxy) and intercept the sync POST.

The sync request body typically looks like this:

POST /api/v1/terminal/sync HTTP/1.1
Host: backend.fintech.ng
Content-Type: application/json
Authorization: Bearer <terminal_token>

{
  "terminal_id": "TRM-00482917",
  "merchant_id": "MCH-10038821",
  "transactions": [
    {
      "txn_id": "TXN-20260708-001",
      "amount": 5000,
      "currency": "NGN",
      "card_token": "tok_4111xxxx1111",
      "timestamp": "2026-07-08T14:22:10Z",
      "status": "APPROVED"
    }
  ]
}

With no per-transaction signature field, the attacker modifies "amount": 5000 to "amount": 500000 in the intercepted request before forwarding it to the backend. The backend has no way to distinguish the tampered request from a legitimate one — both carry the same valid terminal token.

The network attack is also relevant in agent banking scenarios where dozens of terminals share a single Wi-Fi router at a banking agent location. Compromise of that router compromises every sync batch from every terminal on that network.

Replay attack: same batch, double credit

A separate but related flaw: if the sync endpoint is not idempotent, replaying a valid sync POST credits the merchant again for the same transactions. The backend must maintain a processed_transaction_ids table and reject any txn_id it has already acknowledged.

Without this control, an attacker simply replays the sync POST — with or without modification — and receives duplicate credit. This requires no rooting, no MITM, only access to an already-sent sync request (from network logs, from a cached request in the terminal app, or by replaying from Burp's history).

A correct idempotency check on the backend:

-- On sync receipt, for each txn_id in the batch:
SELECT id FROM processed_transactions WHERE txn_id = $1;

-- If row exists: skip and return already-acknowledged status
-- If row does not exist: process and insert
INSERT INTO processed_transactions (txn_id, processed_at)
VALUES ($1, NOW())
ON CONFLICT (txn_id) DO NOTHING;

The ON CONFLICT DO NOTHING clause handles the race condition where two sync requests for the same txn_id arrive simultaneously — only one insert succeeds.

Why this targets the acquirer, not the merchant

Most POS fraud covered in Nigerian fintech circles focuses on card skimming — the merchant or a third party steals cardholder data. This attack is structurally different. The malicious actor is the merchant. The victim is the terminal fintech acting as acquirer.

When the backend credits ₦500,000 for a ₦5,000 card transaction, the Naira comes from the acquirer's settlement float, not from the cardholder's bank. The cardholder's bank authorised ₦5,000. The inflated amount in the sync is never seen by the card network. The acquirer — Moniepoint, OPay Financial, or whichever fintech issued the terminal — absorbs the loss.

At scale, a coordinated ring of attacker-merchants across multiple agent locations could drain settlement pools by tens of millions of Naira before reconciliation flags the anomalies. Nightly reconciliation catches discrepancies, but settlement often runs in near-real-time — the attacker has already withdrawn funds before the investigation starts.

From a regulatory standpoint, a breach of this kind involving merchant fraud triggers CBN reporting obligations under the Regulatory Framework for BVN Operations. If personal cardholder data was accessed during the attack (card tokens, BVN-linked account references), NDPA notification to the Nigeria Data Protection Commission follows. The terminal fintech is the data controller. The exposure sits on their balance sheet and their compliance record.

The fix: cryptographic integrity from card tap to backend credit

Advice like "validate your inputs server-side" is useless here because the backend cannot know what the correct amount should be without a proof generated at the moment of card acceptance. The fix is cryptographic binding.

1. Per-transaction HMAC using Android Keystore

At the point of card acceptance, generate an HMAC over the canonical transaction fields using a device-bound key stored in the Android Keystore with the SECURITY_LEVEL_STRONGBOX flag. StrongBox keys are backed by a dedicated secure element on supported hardware and cannot be exported, even on a rooted device.

// Key generation at terminal provisioning (run once, store alias)
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_HMAC_SHA256, "AndroidKeyStore")
keyGenerator.init(
    KeyGenParameterSpec.Builder(
        "pos_txn_signing_key",
        KeyProperties.PURPOSE_SIGN
    )
    .setIsStrongBoxBacked(true) // Requires hardware StrongBox
    .build()
)
keyGenerator.generateKey()

// HMAC generation at card tap
fun signTransaction(txnId: String, amount: Long, timestamp: String, merchantId: String): ByteArray {
    val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
    val secretKey = keyStore.getKey("pos_txn_signing_key", null) as SecretKey
    val mac = Mac.getInstance("HmacSHA256")
    mac.init(secretKey)
    val payload = "$txnId|$amount|$timestamp|$merchantId"
    return mac.doFinal(payload.toByteArray(Charsets.UTF_8))
}

// Store HMAC alongside transaction in local SQLite
// Include HMAC in sync POST body
{
  "txn_id": "TXN-20260708-001",
  "amount": 5000,
  "currency": "NGN",
  "timestamp": "2026-07-08T14:22:10Z",
  "merchant_id": "MCH-10038821",
  "hmac": "a3f9c2...base64encodedhmac..."
}

2. Server-side HMAC validation before credit

The backend holds the public counterpart of the device key (or a server-side HMAC key registered at terminal provisioning via mutual TLS). Before crediting any transaction from a sync batch, validate the HMAC:

// Pseudocode — backend sync handler
function processSyncBatch(terminalId, transactions) {
  const terminalKey = getRegisteredHmacKey(terminalId); // fetched at provisioning

  for (const txn of transactions) {
    const expectedHmac = hmacSha256(
      terminalKey,
      `${txn.txn_id}|${txn.amount}|${txn.timestamp}|${txn.merchant_id}`
    );

    if (!timingSafeEqual(expectedHmac, Buffer.from(txn.hmac, "base64"))) {
      flagTerminal(terminalId, "HMAC_MISMATCH");
      rejectTransaction(txn.txn_id, "INTEGRITY_FAILURE");
      continue;
    }

    if (isAlreadyProcessed(txn.txn_id)) {
      acknowledgeWithoutCredit(txn.txn_id); // idempotent response
      continue;
    }

    creditMerchant(txn);
    markProcessed(txn.txn_id);
  }
}

Note the use of timingSafeEqual — a naive string comparison leaks timing information that can assist an attacker in forging HMACs incrementally.

3. Terminal hardening at MDM provisioning

4. Anomaly alerting on sync timing

Flag any terminal whose sync batch contains offline transactions with a gap between the transaction timestamp and the sync timestamp that exceeds your operational threshold — 4 hours is a reasonable baseline for Nigerian network conditions. A 72-hour-old offline batch arriving in a single sync is a signal worth investigating, not auto-crediting.

-- Alert rule: offline gap anomaly
SELECT terminal_id, txn_id, timestamp, synced_at,
       EXTRACT(EPOCH FROM (synced_at - timestamp)) / 3600 AS gap_hours
FROM transactions
WHERE EXTRACT(EPOCH FROM (synced_at - timestamp)) / 3600 > 4
  AND created_at > NOW() - INTERVAL '24 hours';
Example finding

Offline sync amount inflation — no per-transaction integrity check

During a POS terminal security assessment, we extracted the terminal APK, reversed the sync implementation, and confirmed no HMAC or signature was generated at card acceptance. We obtained a test terminal, rooted it using a publicly available exploit for its MediaTek chipset, and located the offline transaction database at /data/data/com.fintech.pos/databases/txn_queue.db. We modified the amount column of a queued ₦5,000 transaction to ₦500,000 using sqlite3 over ADB. On triggering sync, the backend accepted the batch and credited ₦500,000 to the test merchant account. No validation of amount authenticity at any stage of the sync pipeline. Fix priority: Critical.

Does your POS terminal sync endpoint validate a per-transaction cryptographic signature, or does it trust whatever the terminal reports?

Get a security review

Frequently asked questions

Does PCI DSS cover offline transaction security?

PCI DSS PA-DSS and PCI PTS cover payment application security and physical terminal security respectively. Offline transaction integrity — protecting the queued transaction from tampering — falls under PA-DSS requirements for secure data storage. Many Nigerian fintech deployments have not completed PA-DSS validation for their terminal software.

Can we prevent rooting entirely?

No. For consumer-grade Android hardware, rooting is always possible given physical access. The correct response is to make rooting the device useless by using hardware-backed cryptographic signing that survives root — Android Keystore with StrongBox. Even on a rooted device, a key stored in the StrongBox secure element cannot be extracted by an attacker.

How many Nigerian POS terminals are actually running vulnerable software?

We do not have a market-wide figure, but the majority of budget Android POS terminals deployed by smaller fintechs and agent networks in Nigeria do not implement per-transaction HMAC signing. The attack surface is significant, particularly among white-label terminal deployments where the OEM firmware is unmodified.

Why automated scanners miss this

Automated web scanners test HTTP endpoints for injection, authentication bypass, and misconfigurations. They do not decompile Android APKs, inspect local SQLite schemas, reconstruct the trust model between a physical terminal and a backend sync endpoint, or simulate a rooted device submitting a tampered batch. Finding this class of vulnerability requires a combination of APK reverse engineering, physical terminal access, and backend sync logic review — work that cannot be automated away.

Related reading

Blog: Reverse engineering Android fintech APKs · Hardcoded API keys in mobile apps

Services: Penetration testing · Mobile money security