Skip to content

Hidden Picture Allows Cross-Attempt Query Modification via Client-Controlled queryid #86

Description

@cmassoglia

Executive Summary

This issue concerns the Hidden Picture answer handler. An authenticated Moodle participant, Mallory, with the participant-level mod/game:attempt capability can submit the handler with the queryid of another game_queries row when that numeric ID is known or otherwise reachable. In the assessed mod_game snapshot, version 2026051500 (release 2026-05-15), the handler accepts this client-controlled ID, loads the row by ID alone, and passes it to game_update_queries. The updater then writes by that ID without checking the current attempt, game, or user. The narrow demonstrated impact is cross-attempt integrity corruption: the request can overwrite another participant's studentanswer, score, and timelastattempt trace.

The scenario requires an active Hidden Picture game, Mallory's authenticated current attempt, and a known or reachable foreign game_queries.id. It does not establish account takeover, session theft, impersonation, arbitrary database writes, or a reliable method for obtaining foreign IDs. The missing sesskey control is a related state-changing request gap, but this report does not claim a separately demonstrated CSRF exploit. This finding is open and classified as low severity under A01:2025 Broken Access Control and the Moodle practice “Don't trust any input from users.”

I reviewed the assessed source version, the finding evidence, and the available release history. The earliest tagged release in which I verified the same behavior is 2022-03-19, but the repository does not establish the first affected release or a fixed release. No runtime execution, HTTP request, exploit artifact, or exploit output was produced for this task.

Background

The affected component is Moodle's mod_game Hidden Picture activity. The normal page obtains the current course module and activity, requires a logged-in user and mod/game:view, and dispatches the hiddenpicturecheckg action to the Hidden Picture handler. The capability definition describes mod/game:attempt as the ability to do the game as a student. This report uses an authenticated participant with that capability as the conservative attacker model; it does not claim that unauthenticated users can reach the write.

The current attempt lookup is scoped to the current activity and Moodle user:

$select = "gameid=$game->id AND userid=$USER->id and timefinish=0 ";

This selection appears in locallib.php:1210-1223 in game_getattempt. During normal Hidden Picture rendering, hiddenpicture/play.php:351-361 selects a query using attemptid=$hiddenpicture->id and then emits that query's ID in a hidden form field. The form also emits glossaryentryid and submits to attempt.php. Those hidden fields are browser input, not a server-side ownership proof.

The game_queries table contains separate attemptid, gameid, and userid fields, as well as the mutable score, timelastattempt, and studentanswer fields (db/install.xml:96-124). The expected security behavior is therefore that a submitted query must belong to the current attempt, current game, and current user before its response state is changed.

Vulnerability Details

The vulnerable path begins with Mallory's submitted Hidden Picture fields. The form in hiddenpicture/play.php:380-403 is a POST form and emits the query identifier and glossary entry identifier as hidden inputs:

echo '<input type="hidden" name="queryid" value="' . $query->id . "\" />\n";
echo '<input type="hidden" name="glossaryentryid" value="' . $query->glossaryentryid . "\" />\n";

At hiddenpicture/play.php:417-442, the handler trusts those submitted values. It loads the glossary entry by the submitted glossaryentryid, evaluates the submitted answer, loads game_queries using only the submitted queryid, and passes the resulting record together with the current $attempt to the updater:

$responses = data_submitted();
$glossaryentryid = $responses->glossaryentryid;
$queryid = $responses->queryid;
if (!($query = $DB->get_record('game_queries', ['id' => $queryid]))) {
    throw new moodle_exception('hiddenpicture_error', 'game', "The query $queryid not found");
}

game_update_queries($game, $attempt, $query, $correct, $answer);

The decisive failure is in locallib.php:1145-1199, inside game_update_queries. For an existing query with a nonzero ID, the function builds a selection containing only the primary key:

if ($query->id != 0) {
    $select = "id=$query->id";
}

The alternative branch does include attemptid, but it is used only when $query->id == 0. The normal record loaded from game_queries has its existing primary key, so the submitted foreign row follows the ID-only branch. No comparison with $attempt->id, $attempt->gameid, or $attempt->userid occurs before the update.

The updater constructs the write record from the selected row's ID and copies state from Mallory's answer:

$updrec = new stdClass();
$updrec->id = $recq->id;
$updrec->timelastattempt = time();

if ($score >= 0) {
    $updrec->score = $score;
}

if ($studentanswer != '') {
    $updrec->studentanswer = $studentanswer;
}

It then calls $DB->update_record('game_queries', $updrec) at locallib.php:1196. In this call, $score is the $correct boolean calculated for Mallory's submitted answer at hiddenpicture/play.php:430-434, and $studentanswer is the submitted answer. The foreign row's last-attempt timestamp is always refreshed by the updater. The handler subsequently updates the current Hidden Picture state and current game_attempts record using the current $attempt at hiddenpicture/play.php:444-454, so the request can leave Mallory's current attempt and Alice's selected query row with inconsistent traces.

The entry path in attempt.php:195-197 obtains the current attempt with game_getattempt before calling the vulnerable handler. That current-attempt selection does not protect the later query lookup because the submitted queryid is resolved independently. The source also shows no sesskey field in the Hidden Picture form and no demonstrated confirm_sesskey() validation in this write path. This is source evidence about the control flow, not a runtime CSRF observation.

The same ID-only updater branch and client-controlled Hidden Picture query field are present in the tagged releases v2022.03.19, v2022.03.20, 2022.03.21, and 2022.05.10, as well as the assessed 2026051500 snapshot. The available history attributes the relevant code to old formatting and compatibility changes rather than identifying a semantic security introduction. I did not identify a fixing change for this binding failure. The earliest verified tagged release is not claimed as the first affected release, and the first affected and fixed release boundaries remain unestablished by the repository history.

Exploitability Analysis

The source-supported primitive is an authenticated write across the game_queries ownership boundary. Mallory uses her own Moodle session and current Hidden Picture attempt, supplies a foreign nonzero queryid, and reaches the database update for that foreign row. The source does not require administrator or game-management privileges for the assessed scenario; the report nevertheless keeps the attacker prerequisite to the requested participant with mod/game:attempt.

The foreign ID must be known or reachable. The form exposes the current user's query ID, but this source review does not establish that a participant can always discover another participant's ID, nor does it test whether deployment-specific database access, UI behavior, or sequential IDs make discovery practical. Exploitability is therefore scoped to the stated reachable-ID condition rather than to an unqualified enumeration claim.

The demonstrated consequence is limited to Moodle activity-record integrity: a selected game_queries row can receive the submitted answer, the answer's correctness value, and a new last-attempt timestamp. The source does not demonstrate reading Alice's private session, taking over her account, changing arbitrary Moodle tables, crossing into an external system, or obtaining code execution. The later current-attempt update does not expand the foreign-row write into an arbitrary gradebook or administrative operation.

The normal same-attempt query selection in game_hiddenpicture_showhiddenpicture is a source-level allowed-case control: it uses attemptid=$hiddenpicture->id before rendering the query ID. The missing foreign-attempt rejection is the source-level negative control: no equivalent ownership predicate is present when the submitted nonzero ID is processed. Neither control was exercised at runtime. A meaningful regression test should compare a same-attempt query, a different-attempt query in the same game, and a different-game query, while checking that the foreign row and both attempts remain unchanged on rejection.

Proof of Concept

Source-level reproduction procedure (not executed). In a disposable Moodle test site, create active Hidden Picture attempts for Alice and Mallory and identify one game_queries row belonging to Alice's attempt. Give Mallory her own current attempt and a valid glossary entry identifier. Then submit the following request shape to the current activity's attempt.php; the placeholders are deliberately not captured runtime values:

POST /mod/game/attempt.php
Content-Type: application/x-www-form-urlencoded

id=<mallory-course-module-id>&action=hiddenpicturecheckg&queryid=<alice-game-queries-id>&glossaryentryid=<valid-glossary-entry-id>&answer=<mallory-controlled-answer>

The source-level expected result is that game_hiddenpicture_check_mainquestion loads Alice's row by the supplied ID, and game_update_queries selects that row with id=. The updater then prepares timelastattempt, the correctness value, and the non-empty answer for that row before calling update_record. A secure implementation would reject the foreign query and leave Alice's row unchanged.

No runtime execution or exploit artifact is included. In particular, there is no captured HTTP response, database diff, log, screenshot, or asserted success result. No poc/ directory was created.

Remediation

Proposed remediation; no shipped fix was verified. Add a Moodle session key to the Hidden Picture form and validate it with the compatible Moodle confirm_sesskey() mechanism before performing the write. This protects the state-changing request, but it is not a substitute for object ownership validation.

Resolve the submitted query only within the server-derived scope of the current attempt. The lookup or an equivalent pre-update assertion should require all of the following to match the current request context:

  • the submitted queryid;
  • attemptid = $attempt->id;
  • gameid = $attempt->gameid; and
  • userid = $attempt->userid.

Also validate that the submitted glossaryentryid is the entry associated with the validated query and is permitted by the current game. Prefer deriving that value from the validated server-side query rather than trusting the hidden field. The game_update_queries helper should not fall back to an ID-only selection for an existing record; it should receive or enforce the same ownership predicates before writing.

Add regression coverage at the real hiddenpicturecheckg entry point. A same-attempt query should remain an allowed case; a query from another attempt in the same game and a query from another game should be rejected without changes to the foreign row, either attempt, or their score and answer fields. Missing and invalid sesskeys should also be rejected. No verified fixed release is available from the inspected history, so these are proposed tests and not evidence of a shipped correction.

Summary

In mod_game 2026051500, an authenticated participant in an active Hidden Picture attempt can, when a foreign game_queries.id is reachable, direct the response write to that row because the handler trusts the submitted ID and game_update_queries selects existing records by ID alone. The affected fields are the foreign row's studentanswer, correctness-derived score, and timelastattempt. This is a bounded cross-attempt integrity issue, not demonstrated account takeover, session theft, arbitrary database access, or external-system compromise.

The earliest tagged release in which I verified the same source behavior is 2022-03-19; the repository does not establish the first affected release or a fixed release. The assessment is source-confirmed, but no runtime execution or exploit artifact was produced. The useful next validation is the two-user regression procedure described above, with explicit checks that a foreign query is rejected and remains unchanged.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions