Moodle: moodle: formula injection allows arbitrary formula execution via unescaped data export
Description
A flaw was found in moodle. This formula injection vulnerability occurs when data fields are exported without proper escaping. A remote attacker could exploit this by providing malicious data that, when exported and opened in a spreadsheet, allows arbitrary formulas to execute. This can lead to compromised data integrity and unintended operations within the spreadsheet.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Moodle formula injection vulnerability allows arbitrary spreadsheet formulas to execute when exported data is opened, risking data integrity.
Vulnerability
Overview
CVE-2025-67851 is a formula injection vulnerability in Moodle that occurs when data fields are exported without proper escaping [1][2]. The root cause is that exported data, such as CSV, Excel, or ODS files, may contain strings that start with characters like =, +, -, or @ at the beginning, which spreadsheet applications interpret as formulas rather than plain text [3]. The fix introduces a new escape_spreadsheet_formula method that prefixes such values with a single quote to prevent formula execution [4].
Exploitation
A remote attacker can exploit this by providing malicious data through any Moodle feature that allows user input and later exports that data to a spreadsheet [1][2]. No authentication is required if the attacker can submit data that is later exported by an administrator or other user. The attack surface includes grade exports, table exports, and any other data export functionality [3][4].
Impact
When the exported file is opened in a spreadsheet application like Microsoft Excel or LibreOffice Calc, the injected formulas can execute arbitrary formulas [1][2]. This can lead to compromised data integrity, unintended operations within the spreadsheet, and potentially further exploitation if the spreadsheet is used in automated workflows [1][2].
Mitigation
Moodle has patched this vulnerability in commit 2025, as shown by the commits that add formula escaping to the CSV, Excel, and ODS data formats [3][4]. Users should update to the latest Moodle version that includes these fixes. No workaround is available other than avoiding the use of exported spreadsheets from untrusted data.
AI Insight generated on May 19, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
moodle/moodlePackagist | < 4.1.22 | 4.1.22 |
moodle/moodlePackagist | >= 4.4.0-beta, < 4.4.12 | 4.4.12 |
moodle/moodlePackagist | >= 4.5.0-beta, < 4.5.8 | 4.5.8 |
moodle/moodlePackagist | >= 5.0.0-beta, < 5.0.4 | 5.0.4 |
moodle/moodlePackagist | >= 5.1.0-beta, < 5.1.1 | 5.1.1 |
Affected products
2Patches
3aa66bacd0783MDL-72744 table: Improve PHPUnit test
1 file changed · +11 −11
public/lib/table/tests/tablelib_test.php+11 −11 modified@@ -832,27 +832,27 @@ public function test_set_and_render_caption_for_table(): void { public function test_table_exports_escaped_formulas(): void { $table = new flexible_table('tablelib_test_export'); $table->define_baseurl('/invalid.php'); - $table->define_columns(['c1', 'c2', 'c3', 'c4', 'c5', 'c6', 'c7', 'c8']); + $table->define_columns(['c0', 'c1', 'c2', 'c3', 'c4', 'c5', 'c6']); ob_start(); $table->is_downloadable(true); $table->is_downloading('csv'); $table->setup(); $table->add_data([ - 'column0' => "\t=SUM(1+1)", // With tab. - 'column1' => "\r=SUM(1+1)", // With carriage return. - 'column2' => "\n=SUM(1+1)", // With new line. - 'column3' => "=SUM(1+1)", - 'column4' => "=1+1", - 'column5' => "+1+1", - 'column6' => "-1+1", - 'column7' => "@A1", - 'column8' => "-", // Single dash (should not be escaped). + 'column0' => " =SUM(1+1)", // With spaces. + 'column1' => "=SUM(1+1)", + 'column2' => "=1+1", + 'column3' => "+1+1", + 'column4' => "-1+1", + 'column5' => "@A1", + 'column6' => "-", // Single dash (should not be escaped). ]); + $output = ob_get_contents(); ob_end_clean(); - $this->assertEquals("\n'=SUM(1+1),'=SUM(1+1),'=SUM(1+1),'=SUM(1+1),'=1+1,'+1+1,'-1+1,'@A1,-\n", substr($output, 3)); + $matchregex = "/\"?' =SUM\(1\+1\)\"?,'=SUM\(1\+1\),'=1\+1,'\+1\+1,'-1\+1,'@A1,-/"; + $this->assertMatchesRegularExpression($matchregex, $output); } }
29820c5ff4efMDL-72744 core_grades: Escape formulas when exporting spreadsheets
3 files changed · +6 −0
public/lib/csvlib.class.php+3 −0 modified@@ -449,6 +449,9 @@ public function add_data($row) { } } $delimiter = csv_import_reader::get_delimiter($this->delimiter); + foreach ($row as $key => $value) { + $row[$key] = \core\dataformat::escape_spreadsheet_formula($value); + } fputcsv($this->fp, $row, $delimiter, $this->csvenclosure, '\\'); }
public/lib/excellib.class.php+1 −0 modified@@ -207,6 +207,7 @@ public function write_string($row, $col, $str, $format = null) { $col += 1; $celladdress = CellAddress::fromColumnAndRow($col, $row + 1); + $str = \core\dataformat::escape_spreadsheet_formula($str); $this->worksheet->getStyle($celladdress)->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_TEXT); $this->worksheet->getCell($celladdress)->setValueExplicit($str, DataType::TYPE_STRING);
public/lib/odslib.class.php+2 −0 modified@@ -164,6 +164,8 @@ public function write_string($row, $col, $str, $format = null) { if (is_array($format)) { $format = new MoodleODSFormat($format); } + $str = \core\dataformat::escape_spreadsheet_formula($str); + $this->data[$row][$col]->value = $str; $this->data[$row][$col]->type = 'string'; $this->data[$row][$col]->format = $format;
dc57ccc491a2MDL-72744 dataformat: Escape formulas when exporting spreadsheets
4 files changed · +145 −1
public/lib/classes/dataformat.php+35 −0 modified@@ -168,4 +168,39 @@ public static function write_data_to_filearea(array $filerecord, string $datafor return get_file_storage()->create_file_from_pathname($filerecord, $filepath); } + + /** + * Escape formula spreadsheet values. + * + * Check values being used in spreadsheets and make them safe for inclusion. + * Following OWASP recommendations {@link https://owasp.org/www-community/attacks/CSV_Injection}. + * + * @param mixed $value Value to check. + * @return string|null Return escaped formula if detected. + */ + public static function escape_spreadsheet_formula(mixed $value): ?string { + // Allow mixed input; only process strings. + if (!is_string($value)) { + return $value; + } + + // Moodle's null placeholder: exactly one dash. + if ($value === '-') { + return $value; + } + + // Trim only for checking, not for modifying output. + $trimmed = ltrim($value); + if ($trimmed === '') { + return $value; + } + + $formulacharacters = ['=', '+', '-', '@']; + // If trimmed version starts with formula character, escape it. + if (in_array($trimmed[0], $formulacharacters, true)) { + // Prepend single quote if value starts with a formula character. + return "'" . $value; + } + return $value; + } }
public/lib/classes/dataformat/spout_base.php+5 −1 modified@@ -123,7 +123,11 @@ public function start_sheet($columns) { * @param int $rownum */ public function write_record($record, $rownum) { - $row = Row::fromValues($this->format_record($record)); + $rowvalues = $this->format_record($record); + foreach ($rowvalues as $key => $value) { + $rowvalues[$key] = \core\dataformat::escape_spreadsheet_formula($value); + } + $row = Row::fromValues($rowvalues); $this->writer->addRow($row); }
public/lib/table/tests/tablelib_test.php+29 −0 modified@@ -826,4 +826,33 @@ public function test_set_and_render_caption_for_table(): void { $this->expectOutputRegex('/' . '<caption class="inline">' . $caption . '<\/caption>' . '/'); } + /** + * Test formulas are escaped in exported tables. + */ + public function test_table_exports_escaped_formulas(): void { + $table = new flexible_table('tablelib_test_export'); + $table->define_baseurl('/invalid.php'); + $table->define_columns(['c1', 'c2', 'c3', 'c4', 'c5', 'c6', 'c7', 'c8']); + + ob_start(); + $table->is_downloadable(true); + $table->is_downloading('csv'); + + $table->setup(); + $table->add_data([ + 'column0' => "\t=SUM(1+1)", // With tab. + 'column1' => "\r=SUM(1+1)", // With carriage return. + 'column2' => "\n=SUM(1+1)", // With new line. + 'column3' => "=SUM(1+1)", + 'column4' => "=1+1", + 'column5' => "+1+1", + 'column6' => "-1+1", + 'column7' => "@A1", + 'column8' => "-", // Single dash (should not be escaped). + ]); + $output = ob_get_contents(); + ob_end_clean(); + + $this->assertEquals("\n'=SUM(1+1),'=SUM(1+1),'=SUM(1+1),'=SUM(1+1),'=1+1,'+1+1,'-1+1,'@A1,-\n", substr($output, 3)); + } }
public/lib/tests/dataformat_test.php+76 −0 modified@@ -122,4 +122,80 @@ public function test_write_data_to_filearea(string $dataformat): void { $this->assertStringStartsWith($filerecord['filename'], $file->get_filename()); $this->assertGreaterThan(0, $file->get_filesize()); } + + /** + * Data provider for test_escape_spreadsheet_formula. + * + * @return array + */ + public static function escape_spreadsheet_formula_provider(): array { + return [ + 'null stays null' => [ + null, + null, + ], + 'empty string stays empty' => [ + '', + '', + ], + 'Formula with tab' => [ + 'value' => "\t=SUM(1+1)", + 'expected' => "'\t=SUM(1+1)", + ], + 'Formula with carriage return' => [ + 'value' => "\r=SUM(1+1)", + 'expected' => "'\r=SUM(1+1)", + ], + 'Formula with new line' => [ + 'value' => "\n=SUM(1+1)", + 'expected' => "'\n=SUM(1+1)", + ], + 'Formula starting with "="' => [ + 'value' => "=SUM(1+1)", + 'expected' => "'=SUM(1+1)", + ], + 'Formula starting with "+"' => [ + 'value' => "+1+1", + 'expected' => "'+1+1", + ], + 'Formula starting with "-"' => [ + 'value' => "-1+1", + 'expected' => "'-1+1", + ], + 'Formula starting with "@"' => [ + 'value' => "@A5", + 'expected' => "'@A5", + ], + 'Null placeholder' => [ + 'value' => "-", + 'expected' => "-", + ], + 'dash with leading space is not placeholder, so escaped' => [ + ' -', + "' -", + ], + 'dash with trailing space is not placeholder, so escaped' => [ + '- ', + "'- ", + ], + 'Non-formula' => [ + 'value' => "Hello there", + 'expected' => "Hello there", + ], + ]; + } + + /** + * Test escape_spreadsheet_formula. + * + * @dataProvider escape_spreadsheet_formula_provider + * @param string|null $value The value to test. + * @param string|null $expected The expected value after escaping. + */ + public function test_escape_spreadsheet_formula(?string $value, ?string $expected): void { + $this->resetAfterTest(); + + $escapedvalue = dataformat::escape_spreadsheet_formula($value); + $this->assertEquals($expected, $escapedvalue); + } }
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
8- github.com/advisories/GHSA-qfh6-h7j6-fvjvghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-67851ghsaADVISORY
- access.redhat.com/security/cve/CVE-2025-67851ghsavdb-entryx_refsource_REDHATWEB
- bugzilla.redhat.com/show_bug.cgighsaissue-trackingx_refsource_REDHATWEB
- github.com/moodle/moodle/commit/29820c5ff4ef381c7a743091ec5c68ac82903b22ghsaWEB
- github.com/moodle/moodle/commit/aa66bacd0783cbc33528fba9c2adca1f685a59bdghsaWEB
- github.com/moodle/moodle/commit/dc57ccc491a2a04032445a3ee92fd0d335ebd746ghsaWEB
- moodle.org/mod/forum/discuss.phpghsaWEB
News mentions
0No linked articles in our index yet.