← Back to list

How I Got an Event Ticket for 4% of Its Price And the Bug That Allowed It

A real-world lesson in why backend validation isn’t optional when handling payments.

Talib Abdulwahab · 2026-05-14 07:10 · 2 claps · 5.5 min read
#cybersecurity #frappe #daraja #safaricom #mpesa
Open on Medium ↗
Wiki topics: FIN · Fintech & Banking 🌐 · Web Development 🔒 · Cybersecurity

How I Got an Event Ticket for 4% of Its Price And the Bug That Allowed It

A real-world lesson in why backend validation isn’t optional when handling payments.

I was getting into ERPNext. I’d been working with it and wanted to understand it deeper, so I decided to do what any curious developer would I decided to attend a frappe event , Frappeverse Africa 2026, and decided to book a ticket.

I wasn’t looking for a bug. I was just exploring.

But then I saw the URL.

The Setup: A Checkout That Trusted the Client Too Much

I started on the events page, picked a ticket, and began filling in my details.

After entering my details, I reached checkout and selected M-Pesa as my payment method.

I was then redirected to the STK Push page a simple form where you enter your phone number to receive the M-Pesa payment prompt.

Standard stuff. But the URL told a very different story:

https://events.frappe.io/mpesa/stkpush
  ?phone_number=
  &payment_gateway=Mpesa-Payments
  &reference_type=Event+Booking
  &reference_id=B1424
  &title=Payment+for+Frappeverse+Africa+2026
  &description=Payment+for+Event+Booking+%28%23B1424%29
  &payment=562
  &currency=USD
  &amount=25.0
  &redirect_to=%2Fdashboard%2Fbookings%2FB1424%3Fsuccess%3Dtrue

Every parameter that mattered the amount, the currency fully editable by anyone with a browser.

My first thought: what if I change amount =25.0 to amount =1.0?

So I did. I edited the URL directly in the address bar, changing the amount parameter:

https://events.frappe.io/mpesa/stkpush
  ?phone_number=
  &payment_gateway=Mpesa-Payments
  &reference_type=Event+Booking
  &reference_id=B1424
  &title=Payment+for+Frappeverse+Africa+2026
  &description=Payment+for+Event+Booking+%28%23B1424%29
  &payment=562
  &currency=USD
  &amount=1.0
  &redirect_to=%2Fdashboard%2Fbookings%2FB1424%3Fsuccess%3Dtrue

The page reloaded. The displayed amount updated. It now showed $1.00.

I paused. Surely the backend would catch this, right? The STK Push would go out for $1, the server would compare it against the actual booking amount ($25), and reject it. That’s the responsible way to build this.

There was only one way to find out.

The STK Push: Where It All Went Wrong

I entered my phone number and submitted the form. My phone buzzed.

M-Pesa sent me an STK Push prompt — for 1 USD (approximately 130 KES). Not 25. Not even close.

The backend had sent the tampered amount directly to Daraja without ever checking it against the original booking record. The client had told the server what to charge, and the server simply… obeyed.

I still wasn’t sure the booking would confirm. Maybe the callback handler would validate the paid amount against the expected amount and reject the booking.

I paid the 130 KES.(UBRK27T04A)

For a $25 event, I had paid $1. Full booking confirmed. Ticket in hand.

What Actually Happened (The Technical Breakdown)

The vulnerability lived in the mpesa integration by Navari.

The original /mpesa/stkpush endpoint accepted amount as a URL query parameter and passed it straight into the STK Push initiation request to Safaricom's Daraja API. There were three layers of validation that never happened:

1. No server-side amount verification at URL load time. When the stkpush page loaded, the backend should have fetched the actual payable amount from the event booking record using the referance id and compared it against the amount parameter in the URL. It didn't.

2. No amount check before initiating the STK Push. When the user submitted the phone number, the backend built the Daraja API request using the user-supplied amount parameter directly. The correct approach would be to look up the referance_id, find the linked booking, read its grand total , and use that value never what the client said.

3. No amount reconciliation in the payment callback. When Safaricom’s callback came in confirming the transaction, the handler marked the booking as Paid without checking whether the confirmed amount matched the booking’s outstanding balance. This is arguably the most critical failure even if the STK Push amount was wrong, the callback handler was the last line of defense, and it wasn’t guarding anything.

The Fix: PR #99

After I reported this to the Frappe team, developers at frappe investigated and traced the root cause to the integration module . The fix was shipped in Pull Request #99 and merged to v16.

The changes were significant. Here’s what was addressed:

Moving Amount Authority to the Server

The core architectural change was transitioning away from a URL-parameter-driven payment flow to a document-driven lifecycle. The get_payment_url method in MpesaSettings was refactored to create a new Mpesa express document on the server side, perform any necessary currency conversion internally, and return a route-based URL tied to that document's unique ID.

In other words: the amount is now stored in the database when the booking is created, not passed back and forth in a URL where anyone can touch it.

Unique Request IDs

The fix introduced a request_id field a unique identifier generated server-side and linked to Safaricom's CheckoutRequestID. This creates a tamper-proof chain: the STK Push request is tied to a specific database record with a specific amount. Any callback from Safaricom can now be reconciled against that record.

The Broader Lesson: Never Trust the Client

This bug is a textbook example of a parameter tampering attack one of the most common and oldest vulnerabilities in web payments. The OWASP guidelines are clear on this: any value that affects pricing, discounts, or quantities must be validated server-side against authoritative data, never taken at face value from user input.

URL parameters are user input. Query strings are user input. Form fields are user input. If your payment flow allows the client to specify how much they’re paying, you do not have a payment system you have a suggestion box.

The pattern that should govern every payment integration is simple:

  1. Store the expected amount server-side when the transaction is initiated.
  2. When the payment gateway is called, fetch that amount from your database. Do not use a client-supplied value.
  3. When the payment callback arrives, compare the paid amount against the expected amount. If they don’t match, reject the confirmation or flag it for review.

These three checks create a closed loop that no URL manipulation can break.

Responsible Disclosure

To be clear: I did not exploit this at scale. I made a single test transaction to confirm the vulnerability, then immediately reported it to the Frappe team with full reproduction steps, the M-Pesa transaction ID, and screenshots. The developers investigated, confirmed the root cause, and the fix is now live.

This is how bug reporting should work.

Final Thoughts

I found this bug not because I was hunting for it, but because I was curious. A URL parameter caught my eye and I asked “what if?” That curiosity, paired with responsible reporting, led to a real security fix in a widely-used open-source payment integration.

If you’re building payment integrations whether on Frappe/ERPNext, Django, Rails, or anything else treat every value that comes from the client as potentially hostile. Your database is the source of truth for pricing. Keep it that way.

And if you’re building with M-Pesa’s Daraja API specifically: always validate the callback amount against your own records before marking a transaction as complete. Safaricom confirms what was paid it’s your job to verify that matches what was owed.

Found a bug? Report it. The ecosystem is better for it.


메타데이터
post_id
0cf0732b92ce
slug
how-i-got-an-event-ticket-for-4-of-its-price-and-the-bug-that-allowed-it-0cf0732b92ce
url
https://medium.com/@Talib_Abdulwahab/how-i-got-an-event-ticket-for-4-of-its-price-and-the-bug-that-allowed-it-0cf0732b92ce
canonical_url
https://medium.com/@Talib_Abdulwahab/how-i-got-an-event-ticket-for-4-of-its-price-and-the-bug-that-allowed-it-0cf0732b92ce
author_url
https://medium.com/@Talib_Abdulwahab
status
ok
fetched_at
2026-06-26 06:47:43