Commit 28ab7f76 authored by Your Name's avatar Your Name

Fix timing-attack: lock extraction result before signalling video player

Commit match.result, under_over_result, and winning_outcomes atomically to
the DB before sending PLAY_VIDEO_MATCH. A crash after this commit always
recovers the same outcome on restart; before this fix, killing the process
between extract_match() and the bet-settlement commit re-rolled the random
draw on each restart, allowing an attacker to loop until a desired outcome
was selected.

Also adds an idempotency guard at the top of _handle_match_start: if the
result is already committed (crash between lock and settlement), bets are
re-settled from the stored result without re-extraction.

Replaces the random fallback in _handle_play_video_match_done with a DB
lookup of match.result, which is now always committed before the video
player is signalled.
Co-Authored-By: 's avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent 52e3c1d1
...@@ -1769,6 +1769,27 @@ class GamesThread(ThreadedComponent): ...@@ -1769,6 +1769,27 @@ class GamesThread(ThreadedComponent):
match_obj = session.query(MatchModel).filter_by(id=match_id).first() match_obj = session.query(MatchModel).filter_by(id=match_id).first()
match_number = match_obj.match_number if match_obj else 0 match_number = match_obj.match_number if match_obj else 0
# Idempotency guard: if a previous MATCH_START already committed the
# result before being interrupted (crash between lock and bet-settlement),
# skip re-extraction and resume from the stored result instead of
# re-rolling the random draw.
if match_obj and match_obj.result is not None:
logger.warning(
f"⚠️ [EXTRACTION] Match {match_id} already has committed result "
f"'{match_obj.result}' — resuming settlement without re-extraction"
)
_idem_uores = match_obj.under_over_result or "UNDER"
_idem_mres = match_obj.result
_idem_wo: List[str] = []
if match_obj.winning_outcomes:
try:
_idem_wo = json.loads(match_obj.winning_outcomes)
except (json.JSONDecodeError, TypeError):
pass
self._update_bet_results(match_id, _idem_mres, _idem_uores, _idem_wo + [_idem_uores], session)
self._send_play_video_match(fixture_id, match_id, _idem_uores)
return
# Log both IDs for debugging # Log both IDs for debugging
logger.info(f"📊 [MATCH INFO] Match #{match_number} (DB ID: {match_id}), Fixture: {fixture_id}") logger.info(f"📊 [MATCH INFO] Match #{match_number} (DB ID: {match_id}), Fixture: {fixture_id}")
...@@ -1921,18 +1942,24 @@ class GamesThread(ThreadedComponent): ...@@ -1921,18 +1942,24 @@ class GamesThread(ThreadedComponent):
uores, mres, impact, winning_out = extract_match(balance, uopayin, mpayin, match_number, cap, payouts, odds) uores, mres, impact, winning_out = extract_match(balance, uopayin, mpayin, match_number, cap, payouts, odds)
winning_outcomes = [item.strip() for item in winning_out.split(",") if item.strip()] winning_outcomes = [item.strip() for item in winning_out.split(",") if item.strip()]
# Atomically lock result, under_over_result, and winning_outcomes to the DB
# BEFORE signalling the video player. This closes the timing-attack window:
# any crash after this commit recovers the same outcome on restart instead
# of re-rolling the random draw inside extract_match().
match_obj.result = mres
match_obj.under_over_result = uores
match_obj.winning_outcomes = json.dumps(winning_outcomes) if winning_outcomes else None
session.commit()
logger.info(
f"🔒 [EXTRACTION] Result locked for match {match_id}: "
f"result={mres}, uores={uores}, winning_outcomes={winning_outcomes}"
)
# Store the under/over result from video selection calculation # Video player is signalled only after result is durably committed
logger.info(f"Storing under_over_result '{uores}' for match {match_id}")
self._store_under_over_result(match_id, uores)
# Send PLAY_VIDEO_MATCH with calculated result
self._send_play_video_match(fixture_id, match_id, uores) self._send_play_video_match(fixture_id, match_id, uores)
# Perform result extraction immediately after sending PLAY_VIDEO_MATCH
logger.info(f"🔍 [EXTRACTION DEBUG] Starting immediate extraction after PLAY_VIDEO_MATCH for fixture {fixture_id}, match {match_id}")
extracted_result = mres extracted_result = mres
logger.info(f"✅ [EXTRACTION DEBUG] Extraction completed immediately: {extracted_result}, winning outcomes: {winning_outcomes}") logger.info(f"✅ [EXTRACTION DEBUG] Extraction completed: {extracted_result}, winning outcomes: {winning_outcomes}")
# Step 8: Update bet results # Step 8: Update bet results
logger.info(f"💾 [EXTRACTION DEBUG] Step 8: Updating bet results for match {match_id}") logger.info(f"💾 [EXTRACTION DEBUG] Step 8: Updating bet results for match {match_id}")
...@@ -3705,11 +3732,30 @@ class GamesThread(ThreadedComponent): ...@@ -3705,11 +3732,30 @@ class GamesThread(ThreadedComponent):
# Send PLAY_VIDEO_RESULTS message (note: RESULTS plural as per user request) # Send PLAY_VIDEO_RESULTS message (note: RESULTS plural as per user request)
self._send_play_video_results(fixture_id, match_id, extracted_result, winning_outcomes) self._send_play_video_results(fixture_id, match_id, extracted_result, winning_outcomes)
else: else:
logger.error(f"No extracted result found for match {match_id}") # extraction_stats not written yet — check match.result which is committed
# Fallback to random selection # before PLAY_VIDEO_MATCH is sent (idempotency guarantee). Only fall back
fallback_result = self._fallback_result_selection() # to a truly random selection if even match.result is absent.
logger.info(f"Using fallback result for match {match_id}: {fallback_result}") logger.warning(f"No extraction_stats found for match {match_id}, falling back to match.result")
self._send_play_video_results(fixture_id, match_id, fallback_result, []) db_result = None
try:
_fb_session = self.db_manager.get_session()
try:
_fb_match = _fb_session.query(MatchModel).filter_by(id=match_id).first()
if _fb_match and _fb_match.result:
db_result = _fb_match.result
finally:
_fb_session.close()
except Exception as _fb_err:
logger.error(f"Failed to query match.result fallback for match {match_id}: {_fb_err}")
if db_result:
logger.info(f"Using committed match.result '{db_result}' for match {match_id}")
winning_outcomes = self._query_winning_outcomes(match_id)
self._send_play_video_results(fixture_id, match_id, db_result, winning_outcomes)
else:
logger.error(f"No result available for match {match_id} — using random fallback")
fallback_result = self._fallback_result_selection()
self._send_play_video_results(fixture_id, match_id, fallback_result, [])
except Exception as e: except Exception as e:
logger.error(f"Failed to handle PLAY_VIDEO_MATCH_DONE message: {e}") logger.error(f"Failed to handle PLAY_VIDEO_MATCH_DONE message: {e}")
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment