← Back to Writeups
CUSTOMMediumWeb

Камень, ножницы, бумага

XESXOR9/10/20264 min read
#web#custom#medium#favorable_state_branching#session_checkpoint_abuse#win_streak_preservation

Камень, ножницы, бумага

Platform: hackerlab | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-04-04 | Status: Solved Techniques: favorable_state_branching, session_checkpoint_abuse, win_streak_preservation

Summary

Task: a PHP web Rock-Paper-Scissors game required a 100-win streak and exposed progress through session state. Solution: abuse broken session handling where each non-tie creates a new child PHPSESSID, keep only winning branches as checkpoints, and discard losses until the streak reaches 100.

Камень, ножницы, бумага — Hackerlab

Overview

Камень, ножницы, бумага

Target: http://62.173.140.174:10500

This challenge looked like a simple Rock-Paper-Scissors streak game: reach 100 wins in a row and get the flag. The key bug was not in randomness, but in how the PHP application handled session state. Instead of updating one session in place, the server forked new sessions on non-tie outcomes, which allowed us to keep only favorable branches.

Recon

Initial recon showed that the target was a PHP web application, not a raw TCP service. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  • GET / returned the main Rock-Paper-Scissors page.
  • Frontend JavaScript sent requests to /play.php with choice=rock|paper|scissors.
  • POST /play.php returned JSON in the format:
json
{"result":"You won!","botChoice":"scissors","wins":1}

One important behavioral detail was visible early: ties preserved the win counter, while losses reset it.

During testing, a single request revealed something unusual about session handling:

  1. Start with a parent PHPSESSID at wins=0.
  2. Send POST /play.php with choice=rock.
  3. Receive a winning response such as: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍
json
{"result":"You won!","botChoice":"scissors","wins":1}
  1. The response headers also contained:
http
Set-Cookie: PHPSESSID=<new_sid>; path=/
  1. Re-checking GET / with the original session showed that the parent session still had its old state.

That was the clue that the server was not mutating the current session normally.

Failed Approaches

Before identifying the real issue, several natural web/PHP angles were tested and led nowhere:

  • PRNG prediction based on the idea that the bot used predictable PHP randomness such as mt_rand.
  • Source disclosure attempts using .phps, backup files, and path tricks.
  • Parameter manipulation through cookies, headers, and alternate body formats.
  • PHP-specific session tricks such as session injection and related tampering attempts. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

These were dead ends because the challenge was not about predicting the bot or disclosing source code. The bug was entirely in the server's state transition logic.

Root Cause

The application implemented session updates incorrectly.

Instead of taking the current PHPSESSID and updating its wins counter in place, the backend behaved like this:

  • On a win or loss, it returned a new PHPSESSID in Set-Cookie.
  • The original session remained unchanged.
  • On a tie, no new useful branch was needed and the current session stayed valid. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

So each non-tie request effectively created an immutable child checkpoint derived from the current session:

  • parent session: unchanged
  • child session: new state after the round

This produced a branching tree of sessions rather than one evolving session.

That design is catastrophic for a streak game, because it lets the attacker keep a safe checkpoint and only move forward when the branch is favorable.

Checkpoint behavior

Two observations confirmed the bug:

  1. A child session with wins=1 remained at wins=1 even after multiple later requests were sent using that same SID, because those later requests created new child sessions rather than modifying the child itself.
  2. Tie responses returned no beneficial new state and did not damage the current checkpoint, so the old session could simply be reused. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Exploitation

Once the session-fork logic was clear, exploitation became trivial.

Maintain exactly one session ID as the current checkpoint:

  1. Start with an initial PHPSESSID and wins=0.
  2. Repeatedly send choice=rock using that checkpoint SID.
  3. If the result is loss or tie, ignore the response and keep using the old checkpoint SID.
  4. If the result is win, read the new PHPSESSID from Set-Cookie and adopt it as the next checkpoint.
  5. Repeat until the checkpoint reaches wins=100. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Because losses only poison the new child session, not the parent checkpoint, they can be discarded for free. The same is true for ties: they do not reduce progress, so the current checkpoint remains usable.

In other words, the exploit converts a difficult 100-win streak into repeated attempts to find one winning child from the current checkpoint, then moves forward only along that branch.

At wins=100, loading the main page with the final checkpoint SID revealed the flag.

Solver

The following script automates the checkpoint selection logic: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

python
#!/usr/bin/env python3
import re
import requests

URL = "http://62.173.140.174:10500"


def extract_sid(set_cookie):
    if not set_cookie or "PHPSESSID=" not in set_cookie:
        return None
    return set_cookie.split("PHPSESSID=", 1)[1].split(";", 1)[0]


def get_flag(sid):
    r = requests.get(URL, cookies={"PHPSESSID": sid}, timeout=10)
    m = re.search(r'CODEBY\{[^}]+\}', r.text)
    return m.group(0) if m else None


s = requests.Session()
s.get(URL, timeout=10)
current_sid = s.cookies.get("PHPSESSID")
current_wins = 0
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

while current_wins < 100:
    r = requests.post(
        URL + "/play.php",
        data={"choice": "rock"},
        cookies={"PHPSESSID": current_sid},
        timeout=10,
    )
    data = r.json()
    child_sid = extract_sid(r.headers.get("Set-Cookie"))

    if data["result"] == "You won!":
        current_sid = child_sid
        current_wins = data["wins"]

print(get_flag(current_sid))

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍