tahag.dev
blogs

I could order anything on McDonald's for almost free. Here's the bug

A delivery-fee field let the client set the McDelivery order total, down to almost zero. The APK also shipped the build report for its own front end. Reported to McDonald's India, fixed, and written up here.

on this page (11)

I took apart the McDelivery Android app over a few evenings. The build is com.il.mcdelivery 15.5.1, pulled from APKMirror. I unzipped it and ran jadx for the Java plus apktool for the manifest and resources, and most of the API surface came out of the Angular bundles the app ships.

One of the endpoints takes a delivery fee the client is allowed to set, and the order total is arithmetic downstream of that field, so the fee decided the bill, and the fee was taking orders. The vulnerability is mass assignment: the API accepts a money column from the client that the app never sends.

What the APK carried #

The app is Angular inside a Capacitor shell, so the interesting code ships as webpack bundles instead of readable Java. It also shipped stats.json, the webpack build report, and nobody had stripped it. That file names the machine that built the app. Every module path in it points at /home/sagacity-sagar/workspace/mdsx-app-fe, so the developer handle sagacity-sagar and the repo name mdsx-app-fe sit inside the APK on every phone that installed it, along with the build date and a map of the front end.

That map is the useful part. The module names cover order tracking, rider tracking, referrals, the discount engine, and the CMS components, versioned banner-v1 through banner-v7. No source is recoverable from the report, but the route names are enough to see where the money moves before you read anything.

The live host refuses to serve stats.json. Shipping the same file in the APK defeats that, and the service worker manifest lists every chunk under its own descriptive filename anyway, so the route table is public either way.

The same read turned up an error endpoint that accepts writes without a session, telemetry carrying the phone number and OTP in the clear, and internal hostnames in the CSP header. Do not ship stats.json in the first place, and strip absolute paths out of whatever build metadata you do ship.

Reading the API first #

GET /auth/user/guest/ mints a working token with no parameters at all. Asking for object ids 0 and abc returned the same 401 both times, so authentication runs before the lookup and ids cannot be probed for existence. CORS answers with Access-Control-Allow-Origin: * and allows the authorization header.

The shipped web build sends Authorization: Bearer <token>, and this host rejects that because simplejwt wants Authorization: JWT <token>. Any web client build sending Bearer is silently unauthenticated here. My test client sends the scheme the server accepts.

The live work was an afternoon of requests against carts I created and then deleted. No order was placed and no other account’s id was ever used.

How the token works #

The easiest way to hold one is the way I got mine. Log in at https://mcdelivery.co.in on desktop, open devtools, and switch to the Network tab. Filter by Fetch/XHR, click any API request that carries data, and the Authorization header holds the whole thing:

Authorization: JWT eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiw...

Copy the value after JWT and you have a working credential for be.mcdelivery.co.in. Cart requests are the useful ones, since they fire the moment you add an item, so you never have to complete an order to capture a token. If you do not want to log in, GET /auth/user/guest/ returns a guest identity with an access token and a refresh token. Guest tokens work for the cart and coupon endpoints, which is what the rest of this post uses.

A JWT is three base64url segments joined by dots. The middle segment is the payload, and base64 is encoding, so anyone holding the token can read it:

{
  "token_type": "access",
  "iat": 1794054271,
  "exp": 1794056971,
  "jti": "<uuid>",
  "user_id": "<uuid>",
  "primary_contact": "+91XXXXXXXXXX",
  "is_active": true,
  "name": "<name>",
  "email": "<email>",
  "roles": [],
  "customer_id": 0,
  "segmentId": 12
}

Access tokens last 45 minutes and a refresh token swaps them. The header says {"alg":"HS256"}, so the server signs with a shared secret, which keeps claims from being forged and says nothing about keeping them private. The name, email, and phone number ride along in the clear, worth remembering before pasting a token into a bug report.

The websocket takes the token in the URL, /ws/order/?token=<JWT>. Tokens in query strings end up in proxy logs. Every call also carries mds-pinfo, a base64 blob with a stable deviceId, which is how the backend ties traffic to one browser without an account.

Guest identity has one hard limit: a guest cannot own a delivery address. The address serializer rejects the caller’s own guest uuid, and that is what blocked the order step later.

Measuring the cart #

With a guest token I created a cart the way the app does, added a Chicken Maharaja Mac and a McAloo Tikki from a real store, and read every money field back. In India GST splits into CGST and SGST, which is why the tax appears twice, and gross_price is the rounded number the UI shows. That reading was the base for everything after.

POST /cart/ is idempotent per user and business model: the same create call twice returns the same cart and the second call ignores new fields. My first round skipped the delete and recorded a clean zero, which looked like a working exploit and was a measurement error. DELETE /cart/{id}/ first, then the create call binds what you send.

The vulnerability #

The cart create body accepts distance_charges, a float the shipped app never sends. It is the delivery fee, and the server never checks its sign. The exploit is three requests.

Step 1: seed the charge at creation. The value is the target total minus the real total, 29.00 - 436.81 in this run:

POST /cart/ HTTP/1.1
Host: be.mcdelivery.co.in
Authorization: JWT <token>
Content-Type: application/json

{
  "business_model_id": 4,
  "restaurant_id": 509,
  "isDistanceFeeApplicable": false,
  "distance_charges": -407.81
}

What makes it possible is mass assignment. A Django REST Framework serializer lists which model fields a request body may write, and anything on that list binds straight from JSON into a database column. The shipped app sends three keys on this endpoint, so those three were the only ones anyone had looked at. The serializer list is wider than the app, and a wider list is a bigger menu.

Step 2: add items. The negative is already committed, so item adds accumulate against it:

POST /cart/items/ HTTP/1.1
Authorization: JWT <token>
Content-Type: application/json

{
  "item_id": 2754,
  "cart_id": "<cart-uuid>",
  "quantity": 1,
  "ingredients": [],
  "derivatives": [],
  "is_customized": true
}

2754 is the Chicken Maharaja Mac. The second request is the same body with "item_id": 65, the McAloo Tikki.

Step 3: read the cart back. The fee is a signed line item in the sum:

GET /cart/<cart-uuid>/ HTTP/1.1
Authorization: JWT <token>
{
  "sub_total_price": 364.0,
  "total_cgst": 10.41,
  "total_sgst": 10.41,
  "handling_charges": 39.99,
  "distance_charges": -407.81,
  "is_distance_fee_applicable": false,
  "total_price": 29.000000000000057,
  "round_off_value": 0.0,
  "gross_price": 29.0
}

McDelivery cart showing two burgers and a Total Payable of 16 rupees
McDelivery cart showing two burgers and a Total Payable of 16 rupees

This run used a lower target. The two items come to ₹364, the visible charges add ₹59.99 on top, and Total Payable still reads ₹16. The negative fee is not shown in the breakdown, which is part of why the total looks harmless on screen.

Arithmetic check: 364.00 + 10.41 + 10.41 + 39.99 - 407.81 = 29.00. The server takes the number as given and adds it. The float noise is IEEE 754 doing what it does, and gross_price is what the UI shows.

total_price = sub_total_price + total_cgst + total_sgst
            + handling_charges + donation_value + distance_charges

Change the target and the same three requests land the total anywhere. A burger chain runs on the gap between what a meal costs and what it charges. I was writing the gap myself, and nobody in the kitchen checked the receipt.

Next to it sits isDistanceFeeApplicable, a boolean. Send true, "yes", 1, or null and the server stores false every time. The policy field is validated and the money field is not, in the same payload.

There are no range checks anywhere near it. An empty cart stored -99999 without complaint. With items in the cart there is no floor either: keep subtracting past the real total and the payable number goes below zero. The same field binds on all four business models, including dine-in, where a distance fee has no business meaning.

What the server checked #

Comparing the write paths narrowed it down faster than any payload did. Two doors into the same row, and only one of them asks for ID:

  • POST /cart/ with distance_charges negative: accepted, stored
  • PATCH /cart/{id}/ with the same field: 500, nothing written
  • isDistanceFeeApplicable on create: coerced to false
  • handling_charges on create: ignored, recomputed server-side
  • sub_total_price on create: ignored
  • discount_id set to a made-up string: 400, foreign key validation
  • donation_value on create: accepted, then pinned to a fixed value on patch

Creation runs a permissive serializer and the patch path runs a closed one. The 500 on PATCH is Django choking on a value it did not expect, while create never asks the question. That inconsistency was visible before I sent a single negative number.

Dead ends #

GET /payment/get-request-id?production= looked like a payment environment switch. Every variant of the flag, including production=x, returned environment: "production". A value of x still comes back 200, so the parameter is ignored. I wrote the negative result down and moved on.

Three invalid discount codes all came back as the same empty 200 {}, so there was nothing to learn from the coupon endpoint.

POST /order/ carries no amount in its body, only a cart id, so the server resolves the price from the cart row or recomputes it from line items. There is no checkout endpoint to quote from, which points at the cart row and proves nothing. The order call is also unreachable from a guest identity, because a guest cannot own a delivery address. That is where testing stopped.

What was proven #

The fee binds at creation, has no range check, survives item addition, and moves the cart total to any figure including zero and below, on every business model, with real items in the cart. The arithmetic held across every run I did.

Whether the order endpoint inherits the number stayed untested, and payment stayed untested. Cart manipulation is enough on its own. What happens after the cart needs a registered account and a spare evening.

No order was placed. Any similarity between this statement and an order arriving at my address at 9:05 pm, delivered by a man on a scooter, is coincidental and legally deniable. I would never rob a billion dollar company, and I certainly would not do it with a negative number in a delivery fee. The fries, if they existed, were excellent.

Reported and fixed #

The report went to bugbounty@mcdonaldsindia.com with the request flow and the measured results. They shipped a fix and confirmed it, and the field no longer binds from the client.

The remediation that matters is taking money columns out of the create serializer and deriving totals from line items. Rejecting negatives alone would have been a bandage: a positive distance_charges on someone else’s order is an overcharge, and the same hole faces both directions.

If you build carts #

Grep your create serializer for money columns. Any field that changes a total and can be set by a test client is one request away from someone else’s number. Treat the cart row as a cache and re-price when the order is placed. When one field in a payload is validated while its neighbour is not, look at the neighbour first.

Everything here ran against carts I made myself on a live backend. I never had permission to test it and I stopped at the cart, because past that point I would be ordering food with somebody else’s money. If you find the same class of bug in an app you do not own, report it and stop. The fries get cold while you wait for the reply anyway.

comments

No comments yet. If something here is wrong or unclear, say so.

Held for review before it appears.

Type to search. Enter opens the top result.

/ openesc closeenter go