PHPOffice/PhpSpreadsheet · critical · PhpOffice\PhpSpreadsheet\Calculation\Exception
#VALUE!
#VALUE!
Error message
#VALUE!
What it means
This is the second, post-conversion pass of the same guard: scan() re-runs the forbidden-pattern match (<!DOCTYPE for spreadsheet readers, <!ENTITY for the Html reader, NUL bytes tolerated between characters) after toUtf8() has normalized the payload to UTF-8. A hit only at this stage (XmlScanner.php:98-100) means the DOCTYPE/ENTITY construct was invisible in the raw bytes and only became ASCII-readable after charset conversion or after toUtf8() stripped the encoding declaration - the classic shape of an encoding-obfuscated XXE/XEE payload that defeats single-pass, byte-level filters.
Source
Thrown at src/PhpSpreadsheet/Calculation/DateTimeExcel/Date.php:101
// Execute function
$excelDateValue = SharedDateHelper::formattedPHPToExcel($year, $month, $day);
return Helpers::returnIn3FormatsFloat($excelDateValue);
}
/**
* Convert year from multiple formats to int.
*/
private static function getYear(mixed $year, int $baseYear): int
{
if ($year === null) {
$year = 0;
} elseif (is_scalar($year)) {
$year = StringHelper::testStringAsNumeric((string) $year);
}
if (!is_numeric($year)) {
throw new Exception(ExcelError::VALUE());
}
$year = (int) $year;
if ($year < ($baseYear - 1900)) {
throw new Exception(ExcelError::NAN());
}
if ((($baseYear - 1900) !== 0) && ($year < $baseYear) && ($year >= 1900)) {
throw new Exception(ExcelError::NAN());
}
if (($year < $baseYear) && ($year >= ($baseYear - 1900))) {
$year += 1900;
}
return (int) $year;
}
/**View on GitHub (pinned to 65b080eef4)
Solutions
- Treat this signal as hostile by default: reject and quarantine the upload, and log the file for review - DOCTYPE/ENTITY hidden behind an encoding is not produced by legitimate spreadsheet tools.
- If a trusted source genuinely emits it, decode to UTF-8 externally (mb_convert_encoding with the true source charset), strip <!DOCTYPE/<!ENTITY blocks, and load the sanitized copy.
- Do not attempt to weaken XmlScanner or swap the pattern - the double check is what catches encoding-obfuscated payloads.
- For user-supplied HTML/SVG (Html reader), preprocess with an HTML purifier that drops DTD/ENTITY constructs before PhpSpreadsheet sees the input.
Example fix
// before
$spreadsheet = \PhpOffice\PhpSpreadsheet\IOFactory::load('upload.xlsx');
// throws: Detected use of ENTITY in XML ... (payload hidden in non-UTF-8 encoding)
// after - decode, sanitize, then load a rewritten copy
$xml = file_get_contents('upload.xlsx'); // for zip-based xlsx, extract the offending part instead
$xml = mb_convert_encoding($xml, 'UTF-8', mb_detect_encoding($xml) ?: 'UTF-8');
$xml = preg_replace('/<!DOCTYPE[^>]*(\[[^]]*\])?>/s', '', $xml) ?? $xml;
$tmp = tempnam(sys_get_temp_dir(), 'xlsx');
file_put_contents($tmp, $xml);
$spreadsheet = \PhpOffice\PhpSpreadsheet\IOFactory::load($tmp); Defensive patterns
Strategy: try-catch
Validate before calling
// Decode to UTF-8 first, then check for markers the raw scan could not see
function sanitizedCopyForTrustedSources(string $path): string
{
$raw = (string) file_get_contents($path);
$charset = mb_detect_encoding($raw, ['UTF-8', 'UTF-16LE', 'UTF-16BE'], true) ?: 'UTF-8';
$xml = $charset === 'UTF-8' ? $raw : mb_convert_encoding($raw, 'UTF-8', $charset);
return preg_replace('/<!DOCTYPE[^>]*(\[[^]]*\])?>/s', '', $xml) ?? $xml;
}
// Only use for trusted, non-adversarial inputs; for uploads, reject instead. Try / catch
use PhpOffice\PhpSpreadsheet\Reader\Exception as ReaderException;
try {
$spreadsheet = \PhpOffice\PhpSpreadsheet\IOFactory::load($path);
} catch (ReaderException $e) {
if (str_contains($e->getMessage(), 'ENTITY in XML')) {
// Marker appeared only after charset conversion => encoding-obfuscated payload.
// Quarantine + security-log the file; never retry the same bytes.
}
throw $e;
} Prevention
- Default-deny: for user uploads, treat this exception as an attack, not a data problem.
- Restrict accepted upload encodings to UTF-8/UTF-16-with-BOM and reject everything else before parsing.
- Run untrusted HTML/SVG through an HTML purifier that removes DTD/ENTITY constructs before the Html reader sees it.
- Keep PhpSpreadsheet current - encoding-obfuscation bypasses are exactly what post-conversion re-scanning was added for.
- Monitor logs for repeat offenders (same source IP/upload endpoint) when this exception fires.
When it happens
Trigger: Loading a file in a non-UTF-8, non-ASCII-compatible encoding (so the marker is not byte-0x3C '<' in the raw stream) whose converted form contains <!DOCTYPE/<!ENTITY; payloads crafted so the first raw-byte check passes but mb_convert_encoding() to UTF-8 reveals the entity; documents where the encoding attribute removal inside toUtf8() exposes or completes the construct. Same entry points as error 403: IOFactory::load(), reader->load(), scan(), scanFile().
Common situations: Deliberate XXE attempts that encode the payload in UTF-16 or exotic charsets to slip naive scanners; regression/pen-test fixtures for CVE-style PhpSpreadsheet bypasses; rarely, a genuinely benign non-UTF-8 file that carries a DTD and survives conversion.
Related errors
- Locale file not found
- Cloning the calculation engine is not allowed!
- Unsupported binary comparison operator
- Unsupported numeric binary operation
- #NUM!
AI-assisted analysis of PHPOffice/PhpSpreadsheet@65b080eef4 (2026-08-17).
Data as JSON: /api/errors/26af253cb822afcb.
Report an issue: GitHub.