Shrnutí
Motokeska je česká outdoorová hra pro motorkáře a řidiče. Hráč si koupí trasu, objede sérii fyzických míst v krajině a na každém z nich naskenuje QR kód na kovovém štítku, čímž doloží, že tam skutečně byl. Za dokončené trasy získává body a účast ve slosování o ceny. Trasy jsou placeným obsahem a naskenované QR kódy jsou herní měnou – obojí je proto zajímavým cílem útoku.
Na webu motokeska.cz byl proveden cílený bezpečnostní audit, a to na základě výslovného souhlasu správce systému. Audit probíhal bez jakýchkoli zvláštních oprávnění a simuloval reálného útočníka bez přístupu zevnitř.
Napříč čtyřmi různými plochami útoku bylo identifikováno pět zranitelností:
- Voucher kódy jsou vydávány ještě před dokončením platby, což umožňuje generovat neomezené množství voucherů zdarma
- Race condition v endpointu pro uplatnění voucheru umožňuje jedním voucherem aktivovat více tras
- Tokeny z QR keší jsou dostupné přes veřejné API, což umožňuje podvod se soutěží o ceny bez fyzické návštěvy míst
- Uživatelské profilové fotky si ponechávají EXIF metadata, která mohou prozradit GPS souřadnice
- Server přijímá nahrání libovolného souboru kvůli nedostatečné validaci obsahu

Všechny nálezy byly zodpovědně nahlášeny vlastníkovi domény, který chyby potvrdil a udělil souhlas s publikováním tohoto článku.
Vouchery zdarma: jak přeskočit platbu
Motokeska.cz umožňuje uživatelům zakoupit vouchery, které odemykají placené trasy, a to na stránce nákupu voucherů.
Nákupní proces je přímočarý – uživatel vyplní e-mail, zvolí typ voucheru a přejde k platbě přes bránu GoPay.

V čem je problém? Server vygeneruje a vrátí platný voucher kód ještě před dokončením platby.
Při zahájení nákupu prohlížeč volá následující 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}
Server okamžitě odpoví plně platným voucher kódem:
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"
}
Pole coupon_code v odpovědi je okamžitě uplatnitelné – bez ohledu na to, zda uživatel platbu vůbec kdy dokončí. URL platební brány (gw_url) je v odpovědi přítomné, ale server před vydáním kódu nikdy nečeká na potvrzovací callback od GoPay. Generování voucheru a platba jsou tak od sebe fakticky oddělené.
Dopad: kdokoli si mohl vystavit libovolné množství placených voucherů, aniž by kdy zaplatil. Tržby z prodeje voucherů tak stály na tom, že se zákazník rozhodne zaplatit, nikoli na tom, že to systém vyžaduje.
Náprava
Voucher kódy by měly být generovány až po přijetí ověřeného potvrzení o platbě (callbacku) z platební brány. Server nesmí nikdy zahrnout voucher kód do počáteční odpovědi při vytvoření platby – kód by měl být vydán teprve poté, co je úspěšná platba potvrzena na straně serveru.
Jeden voucher, neomezeně tras: zneužití race condition
Motokeska.cz umožňuje přihlášeným uživatelům uplatňovat voucher kódy přes jejich profilovou stránku. Každý voucher je zamýšlen jako jednorázový a měl by odemknout přesně jednu placenou trasu.

Uplatnění obsluhuje endpoint /api/coupons/redeem. Standardní požadavek vypadá takto:
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"}}
Pokud je stejný voucher uplatněn sekvenčně na druhou trasu, server jej správně odmítne:
Tento voucher byl již použit. Každý voucher lze uplatnit pouze jednou.
Pokud je ale odesláno více požadavků na uplatnění souběžně – každý cílící na jinou trasu úpravou parametru selected_route.number – projde každý z nich kontrolou je už tento voucher použitý? dřív, než první z nich stihne zapsat svůj výsledek. Každý požadavek pak uspěje nezávisle a odemkne jinou placenou trasu se stejným voucher kódem.

Dopad: jeden voucher zaplatil tolik tras, kolik si jich kupující chtěl vzít – a platí to i pro vouchery koupené legitimně.
Náprava
Endpoint pro uplatnění musí voucher ověřit a označit jako použitý v jediné atomické operaci – ne ve dvou samostatných krocích. Transakce na úrovni databáze s pesimistickým zámkem (např. SELECT FOR UPDATE) zajistí, že jakmile první požadavek přečte stav voucheru, žádný souběžný požadavek jej nemůže přečíst, dokud první požadavek nedokončí zápis. Požadavek, který dorazí v době, kdy je zámek držen, počká, poté přečte voucher už jako použitý a je správně odmítnut – místo aby jednal podle neaktuální hodnoty a uspěl.
Dokonči trasu bez opuštění domova: jak podvádět ve hře přes API
QR kódy, které hráči skenují, jsou zabudované v kovových štítcích rozmístěných po krajině – na každém místě trasy jeden. Takový štítek vypadá takto:

Naskenování QR kódu na takovém štítku přesměruje hráče na URL ve formátu:
https://motokeska.cz/keska/2025/city/01/14?token=JyOz6oe4gC602nRCW2bV
Parametr token je to, co dokazuje, že jste byli fyzicky na daném místě. Je to hlavní herní mechanika a vstupenka do slosování o ceny.
Problém je, že tyto tokeny jsou dostupné přes veřejný API endpoint. Kdokoli je může získat, aniž by opustil svůj stůl.
Následující požadavek vrátí všechna data o keších pro danou trasu, včetně hodnot token, které jsou normálně získatelné pouze fyzickou návštěvou místa a naskenováním štítku:
GET /api/caches?route_id=12&year=2026 HTTP/2
Host: api-dot-motokeska.ew.r.appspot.com
Authorization: Bearer <JWT>
Odpověď obsahuje token pro každou keš na trase:
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
},
...
]
S těmito tokeny v ruce může útočník sestavit platné požadavky na potvrzení keše a odeslat je, jako by každé místo navštívil – dokončit celou trasu z domova, získat body a zúčastnit se slosování o ceny, aniž by kdy nastartoval motor.

Dopad: ceny by končily u toho, kdo se zeptal API, ne u toho, kdo trasu objel. V sázce je tu důvěryhodnost hry, nejen tržby.
Náprava
Pole qr_code_token nesmí být nikdy vráceno přes API dříve, než byla keš fyzicky naskenována. Token by měl být použit pouze na straně serveru k ověření příchozího požadavku o naskenování – nemá žádný legitimní důvod být odesílán klientovi předem.
Vaše profilová fotka může prozradit vaši polohu
Motokeska.cz umožňuje uživatelům nastavit si profilovou fotku. Co by většina uživatelů nečekala, je to, že jejich fotka může nenápadně prozradit, kde bydlí.
Když uživatel nahraje profilovou fotku, obrázek se uloží do veřejně přístupného Google Cloud Storage bucketu bez jakékoli sanitizace metadat. To znamená, že pokud nahraná fotka obsahuje EXIF metadata – jako většina fotek z chytrých telefonů – tato metadata zůstanou zachována a jsou dostupná komukoli, kdo si soubor stáhne.
Bucket lze procházet přímo přes storage endpoint, aniž by se aplikace vůbec použila:
https://storage.googleapis.com/motokeska-user-images?prefix=profile-images
To vrátí seznam všech uložených objektů, které lze následně hromadně stáhnout:
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
Některé z nahraných fotek:
![]()
Po stažení lze obrázky prozkoumat nástrojem exiftool. Pokud autor před nahráním metadata neodstranil, může fotka prozradit místo, kde byla pořízena. Příklad z iPhonu:
[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
Selfie pořízené doma a použité jako profilová fotka právě prozradilo domácí adresu uživatele komukoli, kdo ví, kde hledat.

Dopad: tady platí uživatelé, ne provozovatel. Všechny profilové fotky šlo hromadně stáhnout a zkontrolovat na souřadnice – bez účtu a bez jakékoli interakce se samotným webem.
Náprava
Z nahraných obrázků by měla být před uložením odstraněna veškerá metadata. To lze provést na straně serveru pomocí nástrojů jako exiftool, ImageMagick, nebo přeuložením (re-encode) obrázku přes knihovnu pro zpracování obrázků – což zároveň slouží jako další vrstva obrany proti poškozeným či škodlivým nahráním.
Nahrát cokoli: když server věří příliš
Motokeska.cz umožňuje uživatelům nahrát profilovou fotku přes jejich profilovou stránku. Server má přijímat pouze obrázky – jenže validaci lze triviálně obejít.
Endpoint pro nahrávání kontroluje pouze hlavičku Content-Type požadavku. Pokud hlavička začíná na image/, je soubor přijat – bez ohledu na to, co soubor ve skutečnosti obsahuje. Útočník může nahrát jakýkoli soubor tím, že pošle hlavičku, která pouze začíná na image/, zatímco tělo obsahuje něco úplně jiného. V požadavku níže je Content-Type dané části image/javascript – žádný skutečný typ média, ale na projití kontroly prefixu to stačí:
POST /api/users/3/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
Server nahrání přijme a uloží soubor do veřejně přístupného Google Cloud Storage bucketu s příponou odvozenou z dodaného Content-Type – nikoli ze skutečného obsahu souboru.
Ve stejném endpointu se skrývá ještě druhý problém. Název souboru uloženého v bucketu je odvozen z uživatelského ID v cestě požadavku. Přidání úvodních nul k uživatelskému ID vytvoří jiný název souboru, přičemž server stále požadavek přijme jako platný:
| Cesta požadavku | Uložený název souboru |
|---|---|
/api/users/3/profile-image |
user_3.jpg |
/api/users/003/profile-image |
user_003.jpg |
/api/users/00000003/profile-image |
user_00000003.jpg |
To znamená, že útočník může nahrát neomezené množství souborů do důvěryhodného Google Cloud Storage bucketu – bucketu, který může být zařazen na whitelist jinými systémy nebo CSP politikami – jednoduše zvyšováním počtu úvodních nul.
Dopad: motokeska.cz by nevědomky hostovala cizí soubory – třeba phishingovou stránku – na doméně, které věří uživatelé i bezpečnostní filtry.
Náprava
Hlavičky Content-Type jsou pod kontrolou uživatele a nesmí být nikdy důvěryhodné jako jediný validační mechanismus. Server by měl prozkoumat skutečný obsah souboru pomocí validace magic bytes a odmítnout cokoli, co neodpovídá známému formátu obrázku. Identifikátor uživatele v cestě pro nahrání musí být před použitím normalizován – úvodní nuly by měly být odstraněny a výsledné ID ověřeno vůči přihlášenému uživateli. Vynucení jednoho aktivního profilového obrázku na uživatele nahrazováním existujících souborů namísto vytváření nových by rovněž odstranilo vektor zneužití úložiště.
Časová osa zveřejnění
9. března 2026 – požádáno o svolení k testování motokeska.cz27. března 2026 – podmínky přezkoumány a upřesněny6. června 2026 – osobní schůzka, vysvětlení, doporučení7. června 2026 – report zaslán vlastníkovi motokeska.cz9. června 2026 – článek publikován
Odměna
Pro motokeska.cz neexistuje žádný bug bounty program a o žádnou odměnu nebylo žádáno – report byl sdílen dobrovolně. Tým motokeska.cz ji přesto zcela z vlastní iniciativy nabídl a s vlastníkem webu Davidem Králem byla dohodnuta odměna ve výši 25 000 Kč.