# Business Logic Flaws: When the bug is the behavior

## Introduction

Most security vulnerabilities exist because the code does something it shouldn't: it trusts unsanitized input, exposes internal state, or fails to enforce access controls.

Business logic flaws are fundamentally different. The code works exactly as written. Authentication is enforced. Input is validated. Every request returns a clean 200 OK. The vulnerability isn't in the implementation, it's in the design of the workflow itself. A feature that behaves correctly in isolation can become exploitable when its assumptions about how users will interact with it don't hold up under adversarial conditions. These flaws live in the gap between what the application does and what the business intended, and no scanner or automated tool will catch them, because there's nothing technically wrong to detect.

To illustrate this, this article walks through a realistic scenario: a loyalty and rewards platform where a well-implemented donation feature becomes exploitable due to a design flaw in its confirmation workflow.

Starting from just 5 points, the exploit scales to 100,000 with direct financial impact. A fully reproducible lab environment is provided so you can follow along and test the vulnerability yourself.

## Understanding the Target

Consider a mature loyalty and rewards platform. It has a WAF running in strict mode, properly configured authentication, well-implemented access controls, and no obvious injection points. Classic technical vulnerabilities are nowhere to be found.

In environments like this, if you want to find something meaningful, the best approach is to stop thinking like a pentester running tools and start using the application as a regular user, paying close attention to how each feature behaves and where the business logic could be flawed.

## Mapping the Feature

While navigating the app as a normal user, one feature caught my attention: the platform had a points system where users could accumulate points and redeem them for rewards such as discounts, free meals, and partner offers. Each user started with a small number of points upon registration, and there was a donation feature that allowed users to transfer points to other accounts.

The legitimate flow worked like this: the sender initiates a point transfer, the server generates a confirmation email containing a validation link valid for 24 hours, and only after the sender clicks that link are the points actually transferred to the receiving account. This two-step process was clearly designed as a security measure to prevent unauthorized transfers.

![legitimate_point_transfer_flow](https://cdn.hashnode.com/uploads/covers/6a325b46e7bad03724fd2cb6/1857228c-12bc-4fae-842a-24b72b27b40a.svg align="center")

I decided to study this donation flow in depth by creating two demo accounts and observing exactly how the application handled each step of the process.

## Spotting the Gap

After mapping the donation flow, something caught my attention. The application required the sender to confirm the transfer by clicking a link received via email, but the points weren't deducted from the sender's balance at the moment of the request. They were only deducted after the confirmation.

This raised a few questions. What happens if I send multiple donation requests before confirming any of them? Does the server check my available balance on each new request, or does it just generate a new confirmation email regardless? And what happens if I then confirm all of those pending transfers at once?

The theory was simple: if the balance is only validated at request time and not locked or reserved, the server would keep issuing confirmation links for points I don't actually have. Each confirmed link would credit the receiver independently, effectively multiplying the original balance out of thin air.

And that's exactly what happened. Starting with just 5 points, I was able to generate an unlimited number of pending donation requests, each producing a valid confirmation link. Once confirmed, the receiving account accumulated far more points than the sender ever had.

In the following section, I'll walk through the full exploitation process step by step. I've also set up a replicated environment in a [GitHub repository](https://github.com/cain-infosec/loyalty-credits-lab) so you can reproduce this vulnerability yourself and follow along.

## Exploitation

After deploying the lab environment, we're provided with two accounts: a sender and a receiver. The goal is straightforward: the receiver is the account where the infinite points will end up. For this scenario, we assume the application allows users to register multiple accounts.

![Vulnerable Application](https://cdn.hashnode.com/uploads/covers/6a325b46e7bad03724fd2cb6/9c0e2a8b-0342-4c27-afa4-657ca9174553.png align="center")

Once logged in with the sender account, we can see our current balance: 5 points. The application also gives us the option to donate those 5 points to the receiver account.

![Initial balanceDonation form showing 5 points transfer to receiver account](https://cdn.hashnode.com/uploads/covers/6a325b46e7bad03724fd2cb6/ba2875ae-38cd-46a2-967c-874bdeb7e875.png align="center")

![](https://cdn.hashnode.com/uploads/covers/6a325b46e7bad03724fd2cb6/15d7a68c-4769-4e5a-bd88-75ae1b85726d.png align="center")

If we check our email inbox, we can confirm that a pending request is waiting for us to approve the point donation.

![Donation request pending confirmation](https://cdn.hashnode.com/uploads/covers/6a325b46e7bad03724fd2cb6/bcb8e8de-cc76-429b-87c6-629c8908e77e.png align="center")

If we go ahead and confirm it, nothing surprising happens. The points are transferred to the receiver account as expected. This is the intended behavior, the happy path.

![Current Balance Once Points are donated](https://cdn.hashnode.com/uploads/covers/6a325b46e7bad03724fd2cb6/4f590dd6-f675-4af1-b5ed-1ba02d8678e0.png align="center")

Now let's put the theory from the previous section to the test. We'll reset the lab and fire multiple donation requests without confirming any of them.

Let's reset the lab and put this theory to the test.

This time, instead of going through the normal donation flow, we're going to intercept the request responsible for initiating the point transfer using our proxy. Looking at the intercepted request, we can see it hits a JSON-based API where the payload contains the receiver's identifier and the amount of points to donate. The application enforces that you can't donate more points than your current wallet balance, but that detail turns out to be irrelevant here.

Why? Because the same request can be sent multiple times, and the server keeps responding with the same success message every single time: "Check your inbox to confirm your donation"

![Donate points request](https://cdn.hashnode.com/uploads/covers/6a325b46e7bad03724fd2cb6/63862881-ae0f-42ed-a247-d0c3dc4e770f.png align="center")

So let's do exactly that. Let's check our inbox and see how many pending requests we've accumulated.

![Pending point transfers](https://cdn.hashnode.com/uploads/covers/6a325b46e7bad03724fd2cb6/d0761cc4-82ee-4c5f-a2c7-20e51accd9ca.png align="center")

As expected, multiple approval requests are sitting there, each one carrying a valid confirmation link for 5 points. Now let's confirm all of them.

![Completed point transfers](https://cdn.hashnode.com/uploads/covers/6a325b46e7bad03724fd2cb6/2efe221d-61ff-4387-9beb-71894f369e9a.png align="center")

And here's the moment of truth. Logging into the receiver account, we can see the result: 50 points. Starting from a sender account that only ever had 5.

![Receiver account with extra points](https://cdn.hashnode.com/uploads/covers/6a325b46e7bad03724fd2cb6/13fb9ca7-3578-4c38-ad40-7afd26783366.png align="center")

The real power comes from compounding rounds. By alternating sender and receiver, each round multiplies the total balance by the number of requests fired. After *r* rounds with a constant fanout of *n*, the balance grows as `initial × n^r` — three rounds at fanout 10 turn 5 points into 5,000. Let's prove it.

This time, instead of working from the sender account, we're going to flip perspectives. From the receiver account, which now holds 50 points thanks to the previous exploit, we're going to donate back to the sender. But with a twist: each donation request will carry 50 points instead of 5, and we're going to fire off multiple requests simultaneously.

For this, Burp Intruder does the job. We set up 3 additional requests using null payloads alongside the original one, giving us 4 total requests. But there's nothing stopping us from scaling this to hundreds or thousands.

![Transfer Request with 50 points](https://cdn.hashnode.com/uploads/covers/6a325b46e7bad03724fd2cb6/e5bd7f7d-5fa8-4d52-955e-fa6cacd5abb8.png align="center")

Looking at the Intruder results and the email inbox, we can confirm that all requests were processed successfully by the server.

![Burp Intruder results showing all 50-point transfer requests processed successfully](https://cdn.hashnode.com/uploads/covers/6a325b46e7bad03724fd2cb6/d45c519e-c0a8-436a-a9fe-760e44e817c9.png align="center")

![](https://cdn.hashnode.com/uploads/covers/6a325b46e7bad03724fd2cb6/1ec05189-d897-4cc8-9840-09e85752fb12.png align="center")

After confirming all the pending transfers, the result speaks for itself: with just 4 requests, we've turned 50 points into 200.

![200 Points obtained](https://cdn.hashnode.com/uploads/covers/6a325b46e7bad03724fd2cb6/4d9479f8-530f-4453-8cae-4420b82075ac.png align="center")

This naturally leads to the next question: can this attack be fully automated? The answer is yes. The following script handles the entire exploitation loop end to end. It takes the number of points per donation, the target account, and the number of iterations as parameters, and runs the whole cycle automatically.

*   \[[Exploit code](https://raw.githubusercontent.com/cain-infosec/loyalty-credits-lab/refs/heads/main/exploit.py)\]
    

With this concept in mind, let's see it in action. We're going to configure the script to send 200 points per donation across 500 requests.

![Exploit in action](https://cdn.hashnode.com/uploads/covers/6a325b46e7bad03724fd2cb6/b3002f97-f643-4f36-8e9a-60b0d6fa9223.png align="center")

> While standard Burp Intruder works cleanly in this lab environment, exploiting this in a mature production application isn't usually this straightforward. Firing multiple concurrent requests like this would likely trigger rate limiting mechanisms or WAF blocks. In a real-world scenario, you would need to tune your requests or rely on more advanced desynchronization techniques to bypass these defenses, such as using Turbo Intruder or leveraging HTTP/2 Single-Packet Attacks to ensure all requests hit the backend within the exact same time window.

Let's verify the result on the platform. Starting from an initial balance of just 5 points, the automated script has scaled it all the way up to 100,000.

![100.000 Points obtained](https://cdn.hashnode.com/uploads/covers/6a325b46e7bad03724fd2cb6/c4bc2d5a-a134-4360-902e-c223f5563900.png align="center")

## Impact

This isn't a theoretical vulnerability sitting in a staging environment. Any platform that implements a points-based rewards system (whether it's an airline loyalty program, a retail chain, a hospitality app, or a fintech cashback platform) could be affected by this exact same class of flaw.

What makes this particularly dangerous is how it scales. As demonstrated in the exploitation section, the balance grows as `initial × n^r` each compounding round multiplies the total by the number of concurrent requests. Three rounds at a fanout of 10 turn 5 points into 5,000. Five rounds turn them into 500,000. The ceiling isn't set by any technical limit; it's set only by how many requests the attacker is willing to send and how many rounds they're willing to run. At a certain point, the specific redemption value per point becomes irrelevant, any conversion rate multiplied by an unbounded balance produces an unbounded loss.

What makes this particularly dangerous is the accessibility. This attack doesn't require shellcode, complex payloads, or zero-days. Any registered user with a proxy and a few minutes of curiosity could pull it off. The barrier to entry is low, and the potential for financial damage scales with every iteration.

Business logic vulnerabilities usually don't trigger WAF rules, they don't show up in vulnerability scanners, and they don't leave obvious traces in logs. But they hit in a way that's immediately understood by anyone looking at a balance sheet.

## Remediation

Before jumping into the fix, let's look at the application's source code to understand exactly where the flaw lives.

### The Bug

**Phase 1: Transfer request**

When a user initiates a transfer, the server validates that the sender has enough credits but never deducts or reserves them:

*   backend/server.js:245-248
    

![Vulnerable code — balance validation without reservation](https://cdn.hashnode.com/uploads/covers/6a325b46e7bad03724fd2cb6/aeed1d5a-76b2-41e8-ad70-2fc6a175717d.png align="center")

The transfer is then stored as pending without modifying the sender's balance:

*   backend/server.js:250-264
    

![Vulnerable code — pending transfer stored without deducting balance](https://cdn.hashnode.com/uploads/covers/6a325b46e7bad03724fd2cb6/ce94fd4f-a9f0-4d66-a09c-5806b3371754.png align="center")

This means a user with just 5 credits can create an unlimited number of 5-credit transfer requests, and every single one will pass validation because **the balance it checks against never changes**.

**Phase 2: Transfer confirmation**

The actual movement of credits only happens when the sender clicks the confirmation link from their inbox:

![Vulnerable code — transfer confirmation with Math.max clamping instead of balance re-validation](https://cdn.hashnode.com/uploads/covers/6a325b46e7bad03724fd2cb6/eec4fc11-adc9-434c-b0d1-149b4a96173e.png align="center")

There are two issues here:

1.  There is **no re-validation of the sender's balance** before executing the debit. The server blindly trusts that the check from phase 1 is still valid, regardless of how many other transfers have been confirmed in between.
    
2.  `Math.max(0, ...)` prevents the balance from going negative. This might seem like a safeguard, but it's actually what guarantees the exploit works. If a user with 5 credits confirms 10 pending transfers of 5 credits each, their balance simply gets clamped to 0 on every confirmation while the recipient receives all 50 credits in full.
    

### The fix

The lab environment is intentionally left vulnerable so you can replicate the exploit yourself. Below is the patched version of both phases. If you want to verify that the fix works, you can apply it directly to the lab's source code.

**Phase 1: Fixed transfer request:**

The core change: credits are deducted immediately when the request is made, not when the confirmation link is clicked. The entire operation runs inside a database transaction that serializes concurrent access, preventing race conditions between simultaneous requests.

```javascript
// backend/server.js — POST /api/loyalty/v1/wallet/transfer-credits

const tx = db.transaction(() => {
    // SQLite's write transaction provides an implicit exclusive lock.
    // In PostgreSQL/MySQL, add FOR UPDATE to this query.
    const account = db.prepare(
        'SELECT * FROM accounts WHERE id = ?'
    ).get(senderId);

    if (credits > account.balance) {
        return { error: 'insufficient_credits' };
    }

    // Deduct balance immediately — credits are now reserved
    db.prepare('UPDATE accounts SET balance = balance - ? WHERE id = ?')
      .run(credits, account.id);

    const confirmToken = crypto.randomBytes(16).toString('hex');
    db.prepare(
        `INSERT INTO transfers
           (confirm_token, sender_id, recipient_email, recipient_wallet,
            recipient_card_ref, locale, credits, state, created_at)
         VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?)`
    ).run(
        confirmToken,
        account.id,
        recipientEmail,
        String(body.recipient_wallet_id || ''),
        String(body.recipient_card_ref || ''),
        String(body.locale || 'en'),
        credits,
        Date.now()
    );

    return { success: true, confirmToken };
});

const result = tx();
if (result.error) {
    return sendJson(res, 422, { error: result.error });
}
```

**Phase 2: Fixed transfer confirmation**

The confirmation now validates expiration, handles refunds for expired links, and no longer needs `Math.max(0, ...)` because the balance was already deducted in phase 1.

```javascript
// backend/server.js — GET /wallet/transfer-credits/confirm?token=...

const tx = db.transaction(() => {
    const transfer = db.prepare(
        "SELECT * FROM transfers WHERE confirm_token = ? AND state = 'pending'"
    ).get(token);

    if (!transfer) {
        return { error: 'invalid_or_expired_token' };
    }

    if (Date.now() - transfer.created_at > 24 * 60 * 60 * 1000) {
        // Expired — refund the reserved credits to the sender
        db.prepare('UPDATE accounts SET balance = balance + ? WHERE id = ?')
          .run(transfer.credits, transfer.sender_id);
        db.prepare("UPDATE transfers SET state = 'expired' WHERE id = ?")
          .run(transfer.id);
        return { error: 'token_expired' };
    }

    const recipient = db.prepare(
        'SELECT * FROM accounts WHERE email = ?'
    ).get(transfer.recipient_email);

    if (recipient) {
        db.prepare('UPDATE accounts SET balance = balance + ? WHERE id = ?')
          .run(transfer.credits, recipient.id);
    }

    db.prepare("UPDATE transfers SET state = 'confirmed' WHERE id = ?")
      .run(transfer.id);

    return { success: true };
});

const result = tx();
if (result.error) {
    return sendJson(res, 422, { error: result.error });
}
```

**Cleanup: Expired pending transfers**

A periodic job to refund credits from transfers that were never confirmed within the 24-hour window:

```javascript
// backend/cron.js

const expiredTransfers = db.prepare(
    "SELECT * FROM transfers WHERE state = 'pending' AND created_at < ?"
).all(Date.now() - 24 * 60 * 60 * 1000);

const refund = db.transaction((transfers) => {
    for (const transfer of transfers) {
        db.prepare('UPDATE accounts SET balance = balance + ? WHERE id = ?')
          .run(transfer.credits, transfer.sender_id);
        db.prepare("UPDATE transfers SET state = 'expired' WHERE id = ?")
          .run(transfer.id);
    }
});

refund(expiredTransfers);
```

Three key changes that make the exploit impossible:

*   **Immediate deduction**. Credits are subtracted from the sender's balance at request time, not at confirmation. A second request for the same 5 credits will now fail validation because the balance already reflects the commitment.
    
*   **Atomic transactions.** Both phases run inside database transactions. In SQLite, the write transaction provides an implicit exclusive lock that serializes concurrent requests. In production databases (PostgreSQL, MySQL), you would add `SELECT ... FOR UPDATE` on the sender's row for row-level locking.
    
*   **Refund on expiration**. If a confirmation link expires after 24 hours or the transfer is never confirmed, the reserved credits are automatically returned to the sender, both at confirmation time and through a periodic cleanup job.
    

### Defense in Depth

The code-level fix above eliminates the root cause, but a robust deployment should also layer additional controls:

*   **Rate limiting.** Throttle the transfer-request endpoint per user (e.g., 5 requests per minute). This doesn't fix the underlying flaw, but it dramatically slows down automated exploitation and raises the attacker's visibility.
    
*   **Anomaly detection.** A user generating dozens of transfer requests in seconds against the same recipient is a strong signal. Alerting on unusual transfer velocity, fan-out patterns, or circular transfers between the same pair of accounts can catch exploitation attempts in progress.
    
*   **Audit logging.** Log every transfer creation, confirmation, expiration, and refund with timestamps and account identifiers. In the vulnerable version, the exploit leaves no obvious traces in standard application logs. Explicit audit trails make post-incident analysis possible.
    

## **Try it yourself**

Everything discussed in this article is fully reproducible in a local lab environment. You can have it running in under two minutes:

[Reproducible lab — Business Logic Flaw](https://github.com/cain-infosec/loyalty-credits-lab)

The repository contains the vulnerable application, two pre-configured test accounts, and the exploitation script. The patched code from the Remediation section is not applied by default, so you can exploit the flaw first and then fix it yourself.

If you found this useful, have questions, or want to share your own experience with business logic vulnerabilities, feel free to reach out.

See you in the next one.
