Kahoodle
mod_kahoodle
Kahoodle is a real-time, game-style quiz activity for Moodle in which all participants answer the same questions simultaneously. The facilitator drives the game (lobby → question preview → question → results → leaderboard → revision) using realtime channels provided by the `tool_realtime` plugin (a hard dependency). Players join via the activity page or a generated QR code, optionally choose a display name and avatar (with `realname`, `optional`, `alias`, and `anonymous` identity modes), score points based on correctness and response time, and see live leaderboards and a podium animation at the end. Question content is versioned so that a question can be edited without altering the historical record of completed rounds. Web services exist for AI-assisted content generation (create instance, add questions, etc.) and for the JavaScript front-end (sortorder, delete, duplicate, preview, playback). The plugin ships with a Privacy API provider, backup/restore, system reports for participants and questions, an ad-hoc auto-archive task, and a single built-in question type (`multichoice`).
The plugin demonstrates a high overall standard of Moodle engineering. Capability checks, validate_context calls, sesskey enforcement on state-changing GET handlers, parameterised SQL throughout, use of the File API for stored content, clean_text/clean_param/format_string at output and input boundaries, a complete Privacy API implementation, backup/restore, report builder integration, and curl_security_helper checks on the avatar download path are all in place.
The most significant finding is a single medium-severity privilege escalation in the mod_kahoodle_duplicate_question web service, which validates capability only on the source round's context and never on the target round, allowing a teacher in one course to inject questions into a kahoodle round in another course (constrained to rounds in the preparation stage and to question content that has been HTML-cleaned). The remaining issues are code-quality observations: non-portable LIMIT 1 SQL in the backup step, one hardcoded English string in a template, and the manage_questions capability using a noclean editor without declaring RISK_XSS. None of these is exploitable by an unauthenticated or low-privileged user against other users.
mod_kahoodle 1.1.0 is a Kahoot-style real-time quiz activity for Moodle 4.5 / 5.0 / 5.1, depending on the tool_realtime plugin for messaging. The codebase is modern, namespaced, well-structured, and follows Moodle conventions throughout.
Strengths
- Consistent use of
require_login()/require_course_login(),require_sesskey()on state-changing GET handlers (view.phpstart/finish/leave/newround), andrequire_capabilityon every privileged operation. - All web service classes call
external_api::validate_context()andrequire_capability()before mutating state. - 100% parameterised database access via
$DB; no string concatenation of user input into SQL. - File handling uses the File API (
file_storage,stored_file,make_pluginfile_url,send_stored_file); QR codes are generated throughcore_qrcodeand served viasend_file. - The avatar download from a profile-picture URL is filtered through
core\files\curl_security_helperto block SSRF, and the rest of the request goes through Moodle'sdownload_file_content()wrapper instead of raw cURL. - Privacy API: full provider with metadata, contextlist, userlist, export, delete-for-context, delete-for-user, and delete-for-users implementations; even handles avatar files.
- Backup/restore with both
userinfo=1anduserinfo=0paths, file annotations, and round-state reset on restore-without-userinfo. - Output classes use
format_string/format_textwith explicit context, and templates uses()-prepared values with triple-stash{{{ }}}to avoid double-escaping. - Anonymous mode is implemented carefully with a deterministic
participantcode = md5(USER->id . sesskey() . roundid)so a session can be resumed but is not predictable from the outside. - Comprehensive test suite (PHPUnit + Behat) under
tests/.
Issues found
- Medium (security):
mod_kahoodle_duplicate_questionvalidates the source round's context only, allowing cross-course question injection into any kahoodle round in preparation stage by a teacher who hasmanage_questionsin some course. - Low (code quality): Backup
set_source_sqluses rawLIMIT 1, which is not portable to Oracle/MSSQL. - Low (compliance): A hardcoded
Scorelabel intemplates/participant/common/participantinfo.mustache. - Low (best practice):
mod/kahoodle:manage_questionsuses an editor withnoclean => trueand writes back into a context where it is then cleaned byclean_text, but the capability is not flagged withRISK_XSSindb/access.php.
No SQL injection, no XSS reachable by participants, no direct database/filesystem/HTTP API misuse, and no exposed unauthenticated state-changing endpoints were identified.
Findings
The mod_kahoodle_duplicate_question external function accepts both a roundquestionid and an optional targetroundid. It validates validate_context() and require_capability('mod/kahoodle:manage_questions', ...) on the source round's context only. The target round, when supplied, is loaded with round::create_from_id($targetroundid) and passed straight through to \mod_kahoodle\local\game\questions::duplicate_question() without any further capability or context check.
A user who has mod/kahoodle:manage_questions in any course can therefore call this web service with a roundquestionid from their own course and a targetroundid from a kahoodle activity in another course where they have no permissions, provided the target round is still in the preparation stage. The result is a new kahoodle_questions record plus a kahoodle_round_questions row inside the target round.
This is a cross-course privilege escalation: a teacher can spam other teachers' rounds with arbitrary questions. Because question text is HTML-cleaned via clean_text before storage, the issue cannot be pivoted into XSS, but it does break the trust boundary between courses and pollutes another course's content.
A secondary integrity bug in questions::duplicate_question() makes the impact more visible: the new kahoodle_questions record copies kahoodleid = $data->kahoodleid from the source question, so the inserted row references the wrong kahoodle activity and shows up as orphaned data when reports query kahoodle_questions.kahoodleid.
Medium risk. The attacker must already be an authenticated teacher (with manage_questions) somewhere on the site, and the target round must be in the preparation stage. The injected content is HTML-cleaned via clean_text before storage, so XSS cannot be pivoted from this. However, this is a real cross-tenant boundary violation: another course's content can be modified, leading to spam, confusion, and orphaned kahoodle_questions records (because of the kahoodleid mismatch bug). Detection by the victim is likely (a strange new question simply appears in their round), but cleanup is required and trust between courses is broken.
The web service is registered as 'ajax' => true in db/services.php with no capabilities declared (capability enforcement is left to the class, which is fine in itself). Inside questions::duplicate_question(), the only target-round check is is_fully_editable(), which is a state check, not a permission check. The frontend (amd/src/questions.js) only ever uses this to copy across rounds within the same kahoodle, so under normal use the bug is invisible — but the API is callable directly.
- Authenticate as a teacher in course
Awho hasmod/kahoodle:manage_questionson a kahoodle activityK1. - From a different course
B, identify a kahoodle activityK2that has a round in thepreparationstage — e.g. by browsing or guessing the round id (or by knowing it from a prior interaction). - From the JavaScript console on any page where the user is logged in, call:
require(['core/ajax'], function(Ajax) {
Ajax.call([{
methodname: 'mod_kahoodle_duplicate_question',
args: {
roundquestionid: <my_roundquestionid_in_K1>,
targetroundid: <preparation_roundid_in_K2>
}
}])[0].then(r => console.log(r));
});
The call succeeds and a new question appears in the target round inside course B's kahoodle, even though the caller has no role in course B.
public static function execute(int $roundquestionid, int $targetroundid = 0): array {
// Parameter validation.
['roundquestionid' => $roundquestionid, 'targetroundid' => $targetroundid] = self::validate_parameters(
self::execute_parameters(),
['roundquestionid' => $roundquestionid, 'targetroundid' => $targetroundid]
);
$roundquestion = round_question::create_from_round_question_id($roundquestionid);
// Validate context and capability.
$context = $roundquestion->get_round()->get_context();
self::validate_context($context);
require_capability('mod/kahoodle:manage_questions', $context);
// Resolve target round if specified.
$targetround = null;
if ($targetroundid) {
$targetround = round::create_from_id($targetroundid);
}
// Duplicate the question.
$newroundquestion = questions::duplicate_question($roundquestion, $targetround);
return ['roundquestionid' => $newroundquestion->get_id()];
}
When a targetroundid is supplied, validate the target context as well and require manage_questions there. For example:
$targetround = null;
if ($targetroundid) {
$targetround = round::create_from_id($targetroundid);
$targetcontext = $targetround->get_context();
self::validate_context($targetcontext);
require_capability('mod/kahoodle:manage_questions', $targetcontext);
}
When source and target belong to different kahoodle activities, the operation should arguably be rejected outright (it is a data-integrity error), since kahoodle_questions.kahoodleid is then guaranteed to mismatch the round's activity.
public static function duplicate_question(round_question $roundquestion, ?round $targetround = null): round_question {
global $DB;
$round = $targetround ?? $roundquestion->get_round();
if (!$round->is_fully_editable()) {
throw new \moodle_exception('noeditableround', 'mod_kahoodle');
}
$data = $roundquestion->get_data();
$time = time();
// Create a new question record.
$question = new \stdClass();
$question->kahoodleid = $data->kahoodleid;
$question->questiontype = $data->questiontype;
Set $question->kahoodleid = $round->get_kahoodleid(); so the new question record always belongs to the kahoodle that owns the target round. Combined with the capability fix above, also assert that $data->kahoodleid === $round->get_kahoodleid() and refuse the operation otherwise (or implement a deliberate cross-activity copy with file area handling).
The backup step uses raw SQL with a LIMIT 1 clause inside set_source_sql() for the round source and inside a correlated sub-query for the question source. LIMIT is MySQL/PostgreSQL/SQLite syntax and is not portable to Oracle (ROWNUM/OFFSET..FETCH) or MSSQL (TOP). Moodle officially supports all four engines; the backup framework calls $DB->get_recordset_sql() directly on this string without applying any $limitnum translation, so the SQL will fail at backup time on Oracle and MSSQL.
This only affects the userinfo=0 (no user data) backup path — the userinfo=1 path uses set_source_table() which is portable.
Low risk. Pure portability/code-quality issue. No security impact; the failure is loud (backup process raises a SQL error on incompatible engines).
Activated by if ($userinfo) / else in define_structure(). Backup is normally invoked by admins or teachers via the standard Moodle backup UI; the failure mode is a backup error on Oracle/MSSQL deployments rather than corrupted data.
$question->set_source_sql(
"SELECT DISTINCT q.*
FROM {kahoodle_questions} q
JOIN {kahoodle_question_versions} qv ON qv.questionid = q.id
JOIN {kahoodle_round_questions} rq ON rq.questionversionid = qv.id
JOIN {kahoodle_rounds} r ON r.id = rq.roundid
WHERE q.kahoodleid = ?
AND r.id = (
SELECT r2.id FROM {kahoodle_rounds} r2
WHERE r2.kahoodleid = q.kahoodleid
ORDER BY CASE WHEN r2.currentstage = 'preparation' THEN 0 ELSE 1 END,
r2.timecreated DESC, r2.id DESC
LIMIT 1
)
ORDER BY q.id ASC",
[backup::VAR_PARENTID]
);
Replace the correlated LIMIT 1 sub-query with an exists/join pattern that does not require row-limiting in the SQL, for example by selecting the chosen round id beforehand into a backup setting (->set_source_array() or computed in PHP) and substituting it via ? parameters. Alternatively use $DB->get_records_sql($sql, $params, 0, 1) outside the backup step to obtain the target round id and then use set_source_table() / set_source_sql() without LIMIT.
// Only the last round.
$round->set_source_sql(
"SELECT r.*
FROM {kahoodle_rounds} r
WHERE r.kahoodleid = ?
ORDER BY CASE WHEN r.currentstage = 'preparation' THEN 0 ELSE 1 END,
r.timecreated DESC, r.id DESC
LIMIT 1",
[backup::VAR_PARENTID]
);
Same pattern: avoid LIMIT 1 in raw backup SQL. Pre-compute the round id outside the backup framework and supply it as a parameter, or filter by a flag column added to mark “this is the most recent round”.
The participant info partial renders a hardcoded English label Score instead of resolving it through the language pack. The plugin already defines $string['score'] = 'Score' in lang/en/kahoodle.php, so the fix is mechanical, but as it stands the label is not translatable into other languages.
Low risk. Pure i18n / coding-standard regression. No security impact.
The label is shown in the bottom bar of every participant screen during gameplay. Visible to anyone who joins a round.
<div class="mod_kahoodle-participant-result-total">
<span class="mod_kahoodle-participant-result-total-label">Score</span>
<span class="mod_kahoodle-participant-result-total-value">{{{totalscore}}}</span>
</div>
Use the existing language string:
<span class="mod_kahoodle-participant-result-total-label">{{#str}}score, mod_kahoodle{{/str}}</span>
The question editing form (classes/form/question.php) registers the rich-text question editor with 'noclean' => true. The plugin then sanitises the value at the API layer in \mod_kahoodle\local\questiontypes\base::sanitize_data() via clean_text(... FORMAT_HTML) before storing it, so in practice raw HTML cannot be persisted. Nevertheless, the capability that grants editing rights, mod/kahoodle:manage_questions, only declares RISK_SPAM | RISK_DATALOSS in db/access.php. By Moodle convention, capabilities that allow users to author HTML rendered to other users (whether or not server-side cleaning is in place) should also flag RISK_XSS, so that administrators can see the risk surface in the role definition UI and treat the capability accordingly when defining custom roles.
Low risk. Server-side cleaning means a real XSS is not exploitable today, but the missing flag is a coding-standard / compliance gap that affects how administrators reason about role permissions.
Question text is stored in kahoodle_question_versions.questiontext and rendered to participants via format_text(... FORMAT_HTML). Because all storage paths go through sanitize_data() → clean_text(), raw <script> injection is rejected. The risk flag is mostly an administrative-transparency concern.
'mod/kahoodle:manage_questions' => [
'captype' => 'write',
'riskbitmask' => RISK_SPAM | RISK_DATALOSS,
'contextlevel' => CONTEXT_MODULE,
'archetypes' => [
'editingteacher' => CAP_ALLOW,
'manager' => CAP_ALLOW,
],
],
Add RISK_XSS to the bitmask:
'riskbitmask' => RISK_SPAM | RISK_DATALOSS | RISK_XSS,
if ($questionformat == constants::QUESTIONFORMAT_RICHTEXT) {
// Rich text mode - use editor.
$mform->addElement('editor', 'questiontext_editor', get_string('questiontext', 'mod_kahoodle'), null, [
'maxfiles' => EDITOR_UNLIMITED_FILES,
'noclean' => true,
'context' => $round->get_context(),
]);
No change required here — the cleaning is already deferred to sanitize_data(). Just make sure the capability change above is applied so the role-definition UI accurately reflects the editor surface.
mod_kahoodle_realtime_event_received callback in lib.php is the central WebSocket-style entry point and is reached via tool_realtime's web service. It does the right thing: it pulls roundid with clean_param(..., PARAM_INT), instantiates the round entity, calls external_api::validate_context() (which performs an internal require_login), gates facilitator-only actions behind require_capability('mod/kahoodle:facilitate', ...), and gates participant-only actions behind is_participant() (which itself checks mod/kahoodle:participate or guest-with-anonymous-mode). All sub-actions go through clean_param for their string inputs.
Anonymous identity mode uses participantcode = md5($USER->id . sesskey() . $roundid) as the per-session participant identifier. This is a deliberate trade-off: the identity cannot be guessed by another user (sesskey is server-only), and the same user resuming the round in the same session lands on the same record — but a re-login produces a new participantcode and the previous anonymous score becomes unreachable. This is documented in the function's docblock.
Profile-picture-to-avatar download path in participants::save_profile_picture_to_avatar() is implemented carefully: it first tries to copy the user's stored f3.png/f3.jpg from user/icon directly out of the file storage, only falling back to an HTTP fetch when there is no stored icon (i.e. gravatar or generated). The fallback compares against the default URL, runs the URL through \core\files\curl_security_helper::url_is_blocked() to prevent SSRF to internal hosts, and uses Moodle's download_file_content() wrapper rather than raw cURL.
QR code (\mod_kahoodle\local\entities\round::serve_qrcode) is generated on the fly from the round's view URL using core_qrcode::getBarcodeSVGcode() and served with send_file(..., 'image/svg+xml'). Although the file area qrcode exists, the file is not persisted in the file store; it is recreated per request. The pluginfile callback gates QR access on mod/kahoodle:facilitate or mod/kahoodle:viewresults, which is the intended behaviour (the QR is shown by the facilitator on a shared screen, not fetched directly by participants).
Privacy provider is fully implemented (metadata, contextlist, userlist, export, delete-for-context, delete-for-user, delete-for-users) and even cleans up stored avatar files alongside the participant rows. This is one of the more thorough provider implementations seen in third-party modules.
Test coverage is good: PHPUnit tests under tests/ cover entities, external functions, output classes, the privacy provider, and the auto-archive ad-hoc task; Behat features cover gameplay flows, joining, identity modes, and richtext questions.