introduction
i have a friend who really likes to share the food he eats. to the point where it gets annoying. whenever he goes out, i get a photo of the food, sometimes the menu, and if im especially lucky, the receipt too
some time ago he went to an asian restaurant chain with a bunch of branches across the uk. the usual photo arrived, followed by the receipt. this time the bottom of it had a loyalty code which could be entered online to collect points


naturally, i decided to do a little trolling and redeem it before he could
so i opened the restaurant's website, made an account, entered the code and got 18 points. that should have been the end of it, but the website felt kinda old. not necessarily bad, just old in a way that makes you wonder whats behind it
it turned out the restaurant was a customer of a uk hospitality software provider. the customer-facing website was their cic (customer interaction centre), with the restaurant's branding put on top
looking around
the frontend was a react spa bundled with webpack and using axios for its api calls. static files came from cloudfront, while the actual site sat behind aws elastic load balancing and an apache backend on ec2. the exact apache and backend versions weren't exposed, which is good, but the rest of the structure was fairly easy to follow from the javascript bundle
at this point my curiosity took over and i didn't notice how i was already trying different loyalty codes. the input accepted pretty much anything because client-side validation only checked whether the field was empty. the actual validation happened in an api request, with the code placed directly in the url
as expected, random guesses didn't get me anywhere. after a few attempts the server returned 429 and told me that too many vouchers had been redeemed in five minutes. so direct guessing was rate limited, which was exactly what i expected
for a moment i thought the frontend might still reveal the exact format. if it had a strict regex, fixed prefixes, checksums or even just a useful length check, i could at least understand how the codes were generated without sending more requests
it didn't. there was only a required field
but while searching the minified source i found something much funnier: a hard-coded example code. entering it didn't contact the server at all. it just returned this message locally:
Success! 42,000 points added... Just kidding. That's an example code.

the receipt
i kept digging through the frontend. there were endpoints for customer details, baskets, menus, loyalty rewards, orders and receipts. most of it looked normal. some things returned generic errors, some needed a valid session, and the loyalty endpoint had its rate limit
then i noticed the "my transactions" section. every transaction had a view button which showed a digital version of its receipt
this wasn't an image or a pdf. clicking view made a request with two numbers: the receipt id and venue id. the api returned a json string containing the complete receipt as html, and react inserted that html into the page with dangerouslySetInnerHTML


that was interesting for two reasons. first, receipt ids looked numeric. second, the digital receipt contained the same loyalty code at the bottom as the paper one
this entire time i was still in a discord call with the friend who sent it. not even half an hour had passed since the original photo
so i had a hypothesis: maybe the receipt request only needed those two numbers. maybe the account page itself was the only thing stopping you from viewing somebody else's receipt
there was one easy way to find out...
one thing leads to another
i requested the receipt i already owned without sending my session cookie. it returned 200 with the complete receipt
then i changed the receipt id by one... another 200... and another receipt...

the ids were sequential, the endpoint didn't require authentication, and it didn't check whether the receipt belonged to me. in a small range from 19990 to 20010, every id returned a receipt. eighteen of the twenty-one receipts included a loyalty code at the bottom
the original rate limit suddenly didn't matter much. i didn't have to guess codes anymore. the system itself was giving me valid ones
it took about a minute to write the proof of concept. it looked something like this:
for receipt_id in range(19990, 20011):
receipt = fetch receipt
code = regex extract loyalty code from receipt html
if code exists:
redeem code w/ my credentials

i limited it to five successful redemptions. i didn't wanna keep taking codes from real receipts just to make a point i had already proven
it worked immediately:
receipt 19991 -> +36 points
receipt 19992 -> +35 points
receipt 19993 -> +36 points
receipt 19994 -> +29 points
receipt 19996 -> +31 points
the next attempts returned 429 with too many vouchers redeemed in 5 minutes, but by then the account had already gone from 18 points to 185

those points remained on my account. the restaurant's loyalty scheme gives out free dishes, drinks and other rewards as the balance grows, so this wasn't just a number changing on a profile. the chain ended with something that could be exchanged for actual food
and this was only the loyalty side of it. each exposed receipt also contained the venue, time, order contents, amount paid, server name and other transaction details
disclosure
i reported the issue privately to the provider with the affected tenant, the two relevant api behaviours, a minimal proof of concept, redacted output and the exact change to my points balance
i also suggested requiring authentication and receipt ownership checks, replacing predictable receipt urls with access tokens, removing loyalty codes from unauthenticated receipt responses, and applying limits to receipt retrieval rather than only redemption

its now been a few weeks and i still haven't received a response :c
conclusion
all of this started because my friend wouldn't stop sending photos of his food
less than half an hour after he sent one receipt, i had gone from stealing his 18 points as a joke to watching my account fill itself from other people's receipts
it was a fun "eureka" moment for about five seconds, followed by the much less fun realization that i will have to write an email disclosing it now...