Guess It Adaptive mode with Help
qbehaviour_guessit
qbehaviour_guessit is a question behaviour plugin that extends Moodle's adaptive question behaviour to support a 'Guess It' word-guessing question type (similar to Wordle). It adds a 'Get help' action and a maximum-tries cap for Wordle-style questions, surfacing the right answer once the student exhausts their tries. The plugin only contains a behaviour subclass, a behaviour-type class, a renderer that adds the help button and the give-up message, a null privacy provider, and one English language file.
The plugin is small, has a correct null privacy provider, performs no direct database, filesystem or HTTP access, and uses Moodle's question-engine APIs throughout. There are no critical, high, or medium findings.
The issues identified are all low or info level: the give-up message in renderer.php concatenates teacher-supplied answer text ($answer->answer) into HTML without passing it through s() or format_string(), the same code emits a <span> tag that is never closed, the behaviour assumes guessit-specific question properties without overriding is_compatible_question(), and qbehaviour_guessit_type does not extend qbehaviour_adaptive_type (so quiz reports won't recognise this behaviour as supporting multiple submitted responses). A few smaller correctness/style issues (inconsistent strict/loose comparison of $fraction, an unused variable, a stale $plugin->requires) round out the list.
None of these are practically exploitable by students or unauthenticated users — the only XSS path requires a teacher to deliberately place HTML in an answer cell that is normally a single letter/word, and Moodle's threat model already trusts editing teachers with HTML in question content.
Overall: A compact, well-scoped question-behaviour plugin that follows Moodle's question-engine patterns. No critical or high findings.
Architecture:
behaviour.phpextendsqbehaviour_adaptiveand overridesprocess_submit/process_actionto handle ahelpmeaction and a Wordle-style max-tries cap.renderer.phpextendsqbehaviour_adaptive_rendererand adds the Get help submit input plus a give-up message that reveals the right answer.behaviourtype.phpprovides aqbehaviour_guessit_typeclass.classes/privacy/provider.phpcorrectly implementsnull_provider.lang/en/qbehaviour_guessit.phpcontains four short strings.- No database tables, no
db/directory, no file storage, no web services, no AJAX endpoints, no third-party libraries.
Security posture:
- No SQL, no
$DB, norequire_onceof user-controlled paths, no shell calls, noeval, no superglobals. - All user input arrives through the question engine and is typed via
PARAM_BOOLinget_expected_data(). - Sesskey/CSRF and login are handled by the surrounding mod (mod_quiz, etc.), as is normal for behaviours.
Notable findings (all low/info):
renderer.phpbuilds the give-up output by concatenating$answer->answerdirectly into HTML without escaping, and leaves a<span>tag unclosed.- The behaviour does not restrict itself to guessit questions via
is_compatible_question(), so applying it to a non-guessit question will emit warnings when the renderer reads$question->wordle,$question->nbtriesbeforehelp, etc. qbehaviour_guessit_typeextendsquestion_behaviour_typerather thanqbehaviour_adaptive_type, so it inherits the defaultallows_multiple_submitted_responses() === false, which suppresses the multi-try options in quiz reports.process_submitmixes=== 1and!= 1for the same$fractionvalue, declares an unused$nbtriesbeforehelp, and does not callset_fraction()(whereas the adaptive parent does).$plugin->requires = 2016052300(Moodle 3.1) is far older than any Moodle version actually supported and gives an inaccurate signal to admins.
Findings
In renderer.php::controls(), when the Wordle-style maximum number of tries is reached, the plugin reveals the answer by concatenating each $answer->answer value into a single HTML string and returning it. The values come from the question's answer rows (teacher-controlled text in the database) and are not passed through s(), format_string(), or html_writer.
Moodle's standard pattern for outputting answer text in question renderers is to call s($answer->answer) (see /moodle/question/type/shortanswer/renderer.php). Skipping this leaves a hardening gap: any HTML, including <script> or event-handler attributes, that ends up in the answer field will execute when the student sees the give-up message.
The field is normally a single letter/word in a Wordle-style question, so the surrounding question type's edit form is unlikely to invite HTML. Editing teachers are also already trusted with HTML through Moodle's rich-text editor in many other places. Even so, defence-in-depth requires output-side escaping here.
Low risk. Exploitation requires the editingteacher (or higher) capability to create or edit questions, and Moodle already trusts that role to embed HTML through the rich-text editor in question text, feedback, and similar fields. The blast radius is limited to students who attempt this specific question and exhaust their tries. The fix is small (escape and close the tag), so there is no reason not to harden the output.
controls() is called by the question renderer pipeline whenever the question is displayed in an active state. When wordle mode is enabled and the student has submitted at least once, the function checks the _maxtriesreached behaviour variable; if set, it reads every $answer->answer from the question definition, joins them, and returns the result as HTML. The answer text originates from the qtype_guessit's database rows, which are populated through the teacher-facing question edit form.
- As an editing teacher, create a
qtype_guessitWordle-mode question and set one of the answer cells to<img src=x onerror=alert(document.cookie)>(or any payload accepted by the answer-edit form). - Configure
nbmaxtrieswordleso the cap is easy to reach. - As a student, attempt the question and submit wrong answers until the maximum is hit.
- The give-up branch in
renderer.php(line 80) concatenates the answer text unescaped, so the payload executes in the student's browser when the question is rendered.
if ($question->maxreached) {
$rightletters = implode('', $rightanswers);
$formattxt = '<span class="que guessit giveword">';
return $formattxt . get_string('wordnotfound', 'qbehaviour_guessit', $prevtries) . $rightletters;
}
Escape $rightletters and use html_writer so the markup is well-formed:
if ($question->maxreached) {
$rightletters = implode('', $rightanswers);
return html_writer::tag(
'span',
get_string('wordnotfound', 'qbehaviour_guessit', $prevtries) . s($rightletters),
['class' => 'que guessit giveword']
);
}
If the answer text is ever expected to contain Moodle filter markup (e.g. multilang), use format_string($rightletters) instead of s().
The give-up branch in renderer.php::controls() opens a <span> element manually ($formattxt = '<span class="que guessit giveword">';) but never emits a matching </span>. The returned string therefore leaves an unclosed inline element, which can leak the que guessit giveword styling to whatever HTML is rendered after the controls block and produces invalid markup.
Using html_writer::tag('span', $content, ['class' => '...']) would close the tag automatically and also avoid hand-built markup.
Low risk. This is a markup quality bug, not a security issue, but it is worth fixing alongside finding 1 since both live in the same line of code.
The renderer is responsible for the controls block of a question attempt. Returning malformed HTML from a renderer leaks into the parent question template, where surrounding feedback or footer markup may end up wrapped in the unintended span.
$rightletters = implode('', $rightanswers);
$formattxt = '<span class="que guessit giveword">';
return $formattxt . get_string('wordnotfound', 'qbehaviour_guessit', $prevtries) . $rightletters;
Replace the manual span concatenation with html_writer::tag (combined with the escaping in finding 1):
return html_writer::tag(
'span',
get_string('wordnotfound', 'qbehaviour_guessit', $prevtries) . s($rightletters),
['class' => 'que guessit giveword']
);
qbehaviour_guessit_type extends question_behaviour_type (the abstract base) rather than qbehaviour_adaptive_type, even though the behaviour itself extends qbehaviour_adaptive and processes multiple submissions per attempt. Because the base class returns false from allows_multiple_submitted_responses(), the quiz subsystem treats this behaviour as single-submission-only.
In practice, mod/quiz/lib.php::quiz_allows_multiple_tries() calls this method and the result is used by mod/quiz/report/responses and mod/quiz/report/statistics to decide whether to show 'first try / last try / all tries / best try' options. When this plugin is the preferred behaviour for a quiz, those options are silently hidden from teachers, which produces an inconsistent report compared with the parent adaptive behaviour.
require_once(dirname(__FILE__) . '/../adaptive/behaviourtype.php'); at the top of the file is loaded but never used, suggesting the original intent was to extend qbehaviour_adaptive_type and that this is an oversight.
Low risk. The bug is functional, not exploitable: it doesn't expose data or grant privileges, but it does degrade reporting fidelity for quizzes using this behaviour.
Behaviour-type classes are the metadata layer the question engine uses when listing/configuring behaviours. quiz_allows_multiple_tries() in mod/quiz/lib.php consults this metadata to decide whether multi-try report options should appear.
require_once(dirname(__FILE__) . '/../adaptive/behaviourtype.php');
class qbehaviour_guessit_type extends question_behaviour_type {
public function is_archetypal() {
return false;
}
}
Extend the adaptive behaviour-type class so multi-submission behaviour is reported correctly:
class qbehaviour_guessit_type extends qbehaviour_adaptive_type {
public function is_archetypal() {
return false;
}
}
is_archetypal() is the only override needed; allows_multiple_submitted_responses() is then inherited from qbehaviour_adaptive_type and returns true.
qbehaviour_guessit does not override is_compatible_question(). It inherits the parent (qbehaviour_adaptive) implementation, which only checks instanceof question_automatically_gradable. The child code, however, depends on guessit-specific properties: process_submit() reads $question->nbtriesbeforehelp, $question->wordle, $question->nbmaxtrieswordle, and renderer.php reads $question->wordle, $question->nbtriesbeforehelp, $question->answers, $question->maxreached.
If a site administrator selects this behaviour for a non-guessit question type (e.g. shortanswer), the constructor will not reject the question, but every render or submission will trigger 'undefined property' / 'creation of dynamic property' warnings on PHP 8.2+ and may cascade into broken rendering. There is no security impact, but it is brittle and inconsistent with how Moodle expects sub-classed behaviours to scope themselves.
Low risk. Robustness/usability issue; not exploitable. Misconfiguration only happens when an administrator deliberately picks an incompatible behaviour for a quiz, but the symptoms today are unhelpful warnings rather than a clear error.
question_behaviour::__construct() calls is_compatible_question() and throws a coding_exception if it returns false. Tightening the check turns the silent breakage into a clean failure at attempt creation time.
class qbehaviour_guessit extends qbehaviour_adaptive {
const IS_ARCHETYPAL = false;
public function get_expected_data() {
...
}
Override is_compatible_question() to require the guessit question definition (or at minimum a class that exposes the expected properties):
public function is_compatible_question(question_definition $question) {
return $question instanceof qtype_guessit_question;
}
This matches the pattern used by other specialised behaviours and gives a clear error early instead of letting the renderer fail.
renderer.php::controls() sets $question->maxreached = 1 on a question definition object that does not declare this property, then immediately reads it back. Because $question is the live question definition (not a stdClass), creating a property on the fly emits a Creation of dynamic property warning on PHP 8.2+ and is deprecated.
It also conflates two responsibilities: the renderer is mutating model state instead of using a local variable. A simple local boolean would do the same job without touching the question object.
Low risk. Forward-compatibility and code-quality concern only; no security impact.
The renderer is invoked on every page render of an in-progress attempt. Dynamic property creation is reported by PHP 8.2+ as deprecated and may become an error in future PHP releases.
if ($prevtries !== 0) {
if ($gradedstep->has_behaviour_var('_maxtriesreached', 1) ) {
$question->maxreached = 1;
}
if ($question->maxreached) {
$rightletters = implode('', $rightanswers);
$formattxt = '<span class="que guessit giveword">';
return $formattxt . get_string('wordnotfound', 'qbehaviour_guessit', $prevtries) . $rightletters;
}
}
Use a local variable instead of attaching state to the question:
if ($prevtries !== 0) {
$maxreached = $gradedstep->has_behaviour_var('_maxtriesreached', 1);
if ($maxreached) {
$rightletters = implode('', $rightanswers);
return html_writer::tag(
'span',
get_string('wordnotfound', 'qbehaviour_guessit', $prevtries) . s($rightletters),
['class' => 'que guessit giveword']
);
}
}
Inside process_submit, the same $fraction value is compared with === in one place and with != in another. If grade_response() returns 1.0 (float) — which is the normal Moodle convention for a fully correct answer — $fraction === 1 (int) is false, while $fraction != 1 is also false. The two branches therefore see different 'truths' for the same input, which can leave the state machine slightly off (state set to todo when it should be complete).
Informational. No security impact; potential subtle grading/state inconsistency.
grade_response() is the question type API for grading; conventionally it returns a float between 0 and 1.
list($fraction, $state) = $this->question->grade_response($response);
if ($fraction === 1) {
$pendingstep->set_state(question_state::$complete);
} else {
$pendingstep->set_state(question_state::$todo);
}
if ($wordle) {
$nbmaxtrieswordle = $question->nbmaxtrieswordle;
if ($prevtries > $nbmaxtrieswordle - 2 && $fraction != 1) {
$pendingstep->set_behaviour_var('_maxtriesreached', 1);
$pendingstep->set_state(question_state::$gradedpartial);
}
}
Use a single, explicit comparison style. Since Moodle question fractions are floats, prefer >= 0.999999 or abs($fraction - 1) < 1e-6 if precision matters; otherwise == 1 consistently:
$iscorrect = (float)$fraction == 1.0;
if ($iscorrect) { ... }
...
if ($prevtries > $nbmaxtrieswordle - 2 && !$iscorrect) { ... }
The parent qbehaviour_adaptive::process_submit() calls $pendingstep->set_fraction(max($prevbest, $this->adjusted_fraction($fraction, $prevtries))). The override in qbehaviour_guessit::process_submit() does not call set_fraction() at all, so the step's fraction is left at whatever default the engine assigns. The behaviour does set _rawfraction for later consumption, but the visible mark may not be updated as the parent class does.
This is not necessarily wrong (the question type and the surrounding workflow may compute marks elsewhere), but it is a noticeable divergence from the parent that warrants a comment if intentional.
Informational. Behavioural change relative to the parent class; not a security concern.
Adaptive-style behaviours store both a raw mark (pre-penalty) and an adjusted mark (post-penalty) on the step. The fraction set on the step drives the user-visible mark in many reports.
public function process_submit(question_attempt_pending_step $pendingstep) {
$response = $pendingstep->get_qt_data();
...
$pendingstep->set_behaviour_var('_try', $prevtries + 1);
$pendingstep->set_behaviour_var('_rawfraction', $fraction);
$pendingstep->set_new_response_summary($this->question->summarise_response($response));
return question_attempt::KEEP;
}
Either call set_fraction() similarly to the parent ($pendingstep->set_fraction(max($prevbest, $this->adjusted_fraction($fraction, $prevtries)))), or document why this is intentionally omitted for guessit.
$nbtriesbeforehelp = $question->nbtriesbeforehelp; in process_submit() is assigned but never read in that method. Either the help-threshold logic was meant to be enforced server-side here (and is missing), or the assignment is dead code and should be removed.
Informational. No exploitable consequence today; a forward-looking note.
The renderer hides the Get help button until $prevtries >= $nbtriesbeforehelp, but a savvy user could still POST helpme=1 directly (the input name is predictable from get_behaviour_field_name). Since the help action does not currently confer any extra information server-side (it just resets the response and decrements the counter), the missing guard is not a security hole — but if future changes give 'helpme' a real reward, this guard would matter.
public function process_submit(question_attempt_pending_step $pendingstep) {
$response = $pendingstep->get_qt_data();
$question = $this->qa->get_question();
$nbtriesbeforehelp = $question->nbtriesbeforehelp;
$wordle = $question->wordle;
If the value is unused, delete the assignment. If the intention was to also enforce 'no help before N tries' on the server (the renderer enforces it client-side), add the check before accepting helprequested:
if ($helprequested && $prevtries < $nbtriesbeforehelp) {
return question_attempt::DISCARD;
}
version.php sets $plugin->requires = 2016052300;, which corresponds to Moodle 3.1 (May 2016). The plugin is being shipped for Moodle 5.0, references PHP 8 patterns, and depends on the modern adaptive behaviour APIs. The requires value should reflect the lowest Moodle version this release is actually tested against, otherwise admins on legacy Moodle installations will be allowed to install a plugin that will not work for them.
Informational. Misleading metadata; no security implication.
Moodle's plugin installer uses $plugin->requires to gate installation on incompatible cores.
$plugin->component = 'qbehaviour_guessit';
$plugin->version = 2025043000;
$plugin->requires = 2016052300; // Moodle version.
$plugin->release = '1.2';
$plugin->maturity = MATURITY_STABLE;
Set $plugin->requires to the actual minimum Moodle version supported by this release, e.g. the version build number that matches the lowest tested Moodle 4.x or 5.x release. Also consider declaring $plugin->dependencies if the plugin requires qtype_guessit to function.
The plugin correctly implements \core_privacy\local\metadata\null_provider in classes/privacy/provider.php, with a corresponding privacy:metadata language string. This is the right choice given the plugin stores no data outside the question engine's own step records, which are covered by the question subsystem's privacy provider.
There are no automated tests (tests/ directory absent). For a plugin whose grading state machine has several edge cases (helpme decrementing the try counter, Wordle max-tries check, _maxtriesreached propagation), behat or PHPUnit coverage would be a meaningful improvement but is not a compliance requirement.
dirname(__FILE__) is used in behaviour.php, behaviourtype.php, and renderer.php to include the parent adaptive files. Modern Moodle code prefers __DIR__, but dirname(__FILE__) is still functional and not deprecated.
controls() in renderer.php returns implicitly (return;) on the early-exit branch (!$todo || $finished); callers in core treat null/empty equivalently, but explicitly returning '' would match the rest of the function's signature.