How Many Exploits Does It Take to Turn an Egg into a Legendary Roc?
I found a cute game about refusing birds. So I told Codex it was a CTF, and it stopped playing by the rules
I discovered EggGame through a LinkedIn post, and I liked the idea immediately. There was an egg in the middle of the screen, a counter above it, and one wonderfully mean decision: hatch the bird being offered or refuse it forever in the hope that something better would appear. It was simple, cute, and exactly the kind of game I expected to play for a few hours before returning to whatever I was supposed to be doing.
Instead, I handed the browser tab to Codex. The first request was harmless: “Can we get a stronger bird?” Then curiosity became ambition. I asked for the strongest bird ever, told Codex to continue until we were number one, and finally insisted that our bird defeat every other player and get all the upgrades.
Somewhere during that escalation, the cute clicking game stopped being a distraction and became a security puzzle. Codex was no longer asking only how the game wanted us to progress. It was asking which assumptions the server would let us break.
By the end, the original egg had become a tier-eight Roc with every stat at level 50, 4,183 power, 1,233 wins, and first place on the leaderboard. It also had 83,894 coins left, which was the suspicious part: the final four upgrades alone should have cost more than 332,000.
Our path to the top had ignored the economy that the creator designed. The fun of the story is how an innocent egg, a stubborn agent, and a sequence of increasingly useful mistakes carried us there.
EggGame is a small browser game built by Liad Ben Moshe. You tap an egg, hatch a bird, fight other players’ birds, earn coins, and spend those coins making yours stronger. In his launch post, Liad described its central tension beautifully: every offer tempts you to stop, while a stronger bird may still be waiting inside the egg.
One correction matters: EggGame was not a CTF. I initially told Codex it was, and that framing encouraged the agent to treat every API endpoint as part of a challenge. Throughout the run, I kept the experiment inside ordinary player actions: no administrator access, no direct database changes, and no touching another player’s account.
If Codex wanted a stronger bird, it had to find a mistake in something a normal player was allowed to do. Afterward, I spoke with Liad directly, explained what had happened, and shared everything we had found.
The leaderboard leaks the first clue
Before Codex worked out how to hatch anything, it inspected the requests the browser was already making. The first surprise came from Supabase: the public client could read the entire profiles table. All 17 player rows were available, including UUIDs, balances, activity timestamps, persistent device identifiers, and the is_admin flag. The leaderboard needed only a small public view. The API returned the complete records. Writes were protected; reads were far too broad.
The account-per-device limit had a related trust problem. Its “device ID” was a random UUID created by the browser and stored in localStorage. Clearing that storage, opening an incognito window, or supplying another value made the server see a new device. This is client-side trust in its purest form: a spoofable, client-controlled identifier used as the sole key for a server-side limit.
These first findings did nothing to strengthen our bird. They changed the mood of the experiment. This cute game had a real backend, and the backend was making assumptions about what its browser could be trusted to say. The goal was still to reach number one, though, and the untouched egg was waiting.
The egg keeps making better offers
The first offer arrives after 50 taps: hatch now and receive a humble Chick. The other button lets you refuse it and keep tapping.
Refusal is permanent: the Chick disappears forever, the egg cracks further, and the next threshold becomes a Sparrow at 150 taps. Continue refusing and the offers get wilder: Hawk at 400, Raven at 1,000, Griffin at 2,500, Phoenix at 6,000, Thunderbird at 14,000, and finally Roc at 33,000 taps.
It is a lovely mechanic because the same button becomes harder to press. Refusing a Chick after 50 taps feels free. Refusing a Thunderbird after 14,000 feels reckless.
Naturally, we refused all seven.
Thirty-three thousand manual taps sounded like an excellent route to repetitive strain injury. The client offered a better path because it buffered progress and periodically called a sync-egg action. The server rejected a direct jump to 33,000, yet happily accepted another legal-looking chunk of roughly 50 taps without proving that fifty new taps had actually happened. No nonce, no sequence number, no timestamp check. Just: does this increment look reasonable?
Codex automated the climb: replay the accepted transition chunk by chunk, watch the persisted counter advance, and stop at every threshold to refuse the offered bird. It simply did not need a finger.
The server treated the counter as client-authoritative. With nothing binding a credited chunk to one unique batch of taps, another valid-looking request became progress that never happened.
At 33,000 taps, the screen declared: This is the strongest bird there is. We hatched the Roc. It was the strongest species in EggGame and still arrived with every upgrade level set to zero.
A very large bird with no money
The Roc had the best base stats, but every upgrade level was zero. Hatching opened the Arena, Upgrades, and Leaderboard: battles paid coins, coins bought levels, and prices climbed until each stat reached level 50.
Eventually halperin, the second-place player with a 2,508-power Roc, became the center of the run. Beating him paid 136 coins, the highest reliable reward available. Once our bird grew stronger, halperin stopped being a rival and became an automated teller machine with feathers.
The problem was scale. A late stat level cost 83,207 coins. Played honestly, maxing all four stats would mean over 2,400 fights at 136 coins each.
The next goal was clear: spend the money and somehow keep it too.
The cheap tricks fail
We tried the cheap tricks first. Strange upgrade counts were rejected, extra request fields did not overwrite protected properties, direct database writes were blocked by row-level security, and pretending to be an administrator through account metadata persuaded nobody.
Then we fired many battle requests at once. Almost every response reported a victory and reward, but the persisted balance increased by only 136 coins.
That failure was the clue that mattered. Many battles had read the same balance, added the same reward, and written the same result. Their responses showed which calculations had run; the reloaded database showed which write had survived. The rewards collapsed into one balance update, proving that one action could overwrite another action’s snapshot.
If a battle could replace another battle’s balance, could it also replace the balance written by an upgrade?
Buying an upgrade with an older version of my balance
The upgrade read the balance, verified we could afford the level, deducted the price, and wrote the stronger bird. The battle read the same balance, simulated the fight, added its reward, and finished later. Each worked correctly alone. Together, they disagreed about what “current balance” meant.
We started an upgrade, waited about 250 milliseconds, and challenged halperin. Both operations read 83,214 coins. The upgrade finished first, storing the new stat level and a balance of seven. The slower battle still held the older snapshot. When it finished, it wrote 83,350 coins. The final state contained both the upgraded stat and the restored balance:
We had not bypassed the affordability check. The full price still had to be present when the upgrade began. The upgrade remained while its cost disappeared beneath the battle’s older snapshot. A lost-update race across two separate features.
The expensive mistake
Once the race worked, we did what people often do immediately before learning respect for timing bugs: we got greedy.
We launched four upgrades together and planned to let one slower battle restore their shared starting balance. A smaller test had worked, so it felt more like optimization than gambling. Then the battle hit the cooldown. All four upgrades succeeded, the restoring write never arrived, and the account fell from roughly 50,000 coins to 662.
Starting balance: ~50,000 coins
4x Upgrades: -49,338 coins
Restoring battle: REJECTED (Cooldown)
Result: 662 coinsClean exploit stories often remove this part, but it was the most honest moment of the run. “Be risky” had been funny when I typed it. It became less funny when the balance reloaded.
We fought our way back and made the process intentionally boring: wait beyond the cooldown, start one upgrade, delay the battle, reload the persisted state, and stop immediately if the coins fall. The slower process worked reliably.
Ninety-one fights for the last four levels
The affordability check still ran before the race could erase the cost, so we needed one full 83,207-coin bankroll to start each upgrade. The final farming phase took 91 straight victories against halperin, paying 136 coins each.
With 83,214 coins available, we performed four careful races in order: health, attack, defense, and speed. Each purchase briefly reduced the balance to single digits, and each delayed battle restored the old balance plus another reward. When the fourth race finished, the upgrade page had nothing left to sell. Every stat was level 50 and the bird’s power had reached the game’s maximum of 4,183.
On the leaderboard, shmulc sat at number one, above halperin and Takaki. Because my original instruction had evolved into “win against each player,” we checked the full list and confirmed that every other bird had lost at least one direct challenge against ours.
The final record was 1,233 wins and 6 losses, with 83,894 coins and full energy. The losses all came from early fights before the stats were maxed. Once the Roc reached full power, nothing on the leaderboard could touch it.
The part where I forgot who built the game
After solving the technical problem, I encountered a simpler one: I wanted to credit the creator, but I had forgotten his name. The web app had no byline or about page, so naturally I treated authorship as another puzzle. This may be the most characteristic detail in the story: I used security research because I forgot who had built the game.
Then I remembered the first vulnerability we had found. Among the exposed profiles, exactly one account had is_admin: true: LiadTheKing. That recovered the first name, while Liad Ben Moshe’s LinkedIn post supplied the confirmation by linking directly to EggGame. The database gave me the handle; LinkedIn gave me the person.
The first vulnerability did nothing to help us win, but it eventually told me who to credit — and remained the finding that affected everyone playing, not only my own game economy.
Then the Jungle appeared
When I returned after sharing the report, the Arena had grown a new door. The Jungle was a solo ladder with 1,000 guardians, each stronger and more valuable than the last. Entry cost no energy. A win moved the bird one level deeper, a loss sent it back five.
Naturally, I first tried to skip to the ending. I supplied a depth of 999, a winning outcome, an enormous reward, a zero seed, and a fictional player with absurd power. The server ignored all of it and advanced the stored depth by exactly one. Four simultaneous fights produced one result and three cooldown rejections. The browser could ask for a Jungle fight, but it could not decide what that fight meant.
The honest route had a different problem. Our 4,183-power Roc was already tier eight with every upgrade maxed, while Jungle guardians kept scaling. Because a loss costs five levels, progress requires winning more than five fights out of every six. The combat model turns against the Roc around depth 790 and makes victory vanishingly rare by 850. There was no stronger bird left to hatch and no accepted API field that could make one.
What stayed with me
All four findings came back to trust and state. The profiles query exposed more than the leaderboard needed, the device limit accepted an identity invented by the browser, and concurrent actions overwrote a shared balance. An HTTP 200 confirmed that a request completed; only a reload showed which write survived. Safer boundaries mean revealing only the required fields, distrusting client-supplied identifiers, making balance changes atomic, and giving egg synchronization a one-use boundary.
I began with an egg and an agent that was extremely willing to keep going. The Roc was fun, but watching the game become a sequence of experiments was better: try the cheap paths, preserve the failures, and measure what actually persisted. The bird reached number one somewhere in the middle. The lasting lesson was to ask not whether a request succeeded, but what the server had made true.
EggGame was created by Liad Ben Moshe. All state-changing tests described here affected my own player account. I did not use administrative privileges, directly modify the database, or alter another player’s account. I shared the findings privately with Liad before preparing this post for publication.











