Typecho's existing multilingual implementation has quite a few flaws.
审计范围与方法
代码链:install.php/Options\General(lang 选项来源)→ Widget\Init(拼路径)→ I18n::setLang/addLang → GetTextMulti → GetText(MO 解析/翻译/复数)。动态验证在 PHP 8.5.7 CLI 下用真实构造的 .mo 文件进行(留存在 tools/_audit_i18n_tmp/audit.php,可 php tools/_audit_i18n_tmp/audit.php 复现)。
验证结果
| 测试 | 结果 |
|---|---|
translate 多文件回退 |
失效:仅首个文件被查询 |
ngettext 多文件顺序 |
后写覆盖:且"未命中"会覆盖"已命中" |
复数头 eval(cache=off) |
RCE 成立(touch 成功执行) |
复数头 eval(cache=on,默认运行时) |
不可达(头部从未被加载) |
| 畸形复数表达式(cache=off) | 未捕获 ParseError → 致命 |
| 缺失文件析构 | 未捕获 TypeError: fclose(null) |
| 截断/损坏 MO | 构造阶段直接致命 TypeError |
发现 1(中危,潜伏 RCE 原语):selectString() 对 MO 头 eval
361:376:var/Typecho/I18n/GetText.php
$string = $this->getPluralForms();
$string = str_replace('nplurals', "\$total", $string);
$string = str_replace("n", $n, $string);
$string = str_replace('plural', "\$plural", $string);
...
eval("$string");.mo 头部 Plural-Forms: 内容经三次朴素替换后直接 eval。验证用的头部:
Plural-Forms: nplurals=2; plural=n==1?0:1; touch('zzz_mo_rce');//cache=OFF 时调用 ngettext() → 磁盘上真实生成了 zzz_mo_rce(代码注入确认);注入 @@@BROKEN@@@ → 未捕获 ParseError(DoS)。str_replace('n', $n) 会破坏含字母 n 的函数名,但仅限制了 payload 写法,不构成防护。
实际可达性:默认运行时(GetTextMulti → GetText($file, true))loadTables() 缓存循环条件 table_originals[$i*2+1] > 0(248:260)跳过 msgid="" 头部行,因此复数头永远读不到,cache 下 eval 输入固定为默认表达式、不可注入。该 RCE 仅当用 enable_cache=false + ngettext 才会触发——核心代码中只有 Options\General::getLangs() 用 cache=off 且只调 translate(),默认不可达,但这是"数据源驱动 eval"的高危设计,建议彻底消除。
建议:不信任二进制资源里的表达式。对 Plural-Forms 表达式做严格白名单(仅允许 n / nplurals / plural / 数字 / 比较符 / 三元 / 括号 / +-*/%)校验后再 eval,不匹配则回退默认规则;或直接实现一个微型复数表达式解析器,去掉 eval。
发现 2(中危功能缺陷 + 发现 1 的放大器):cache 下复数头被丢弃
248:260:var/Typecho/I18n/GetText.php
if ($this->enable_cache) {
$this->cache_translations = ['' => null];
for ($i = 0; $i < $this->total; $i++) {
if ($this->table_originals[$i * 2 + 1] > 0) { // msgid 长度为 0 的头部被跳过
...结果:运行时(cache=ON)getPluralForms()(384:406)拿到的 cache_translations[""] 恒为 null,任何语言的复数规则都被忽略,一律用英文默认式 nplurals=2; plural=n == 1 ? 0 : 1;。对法语(n>1)、俄语等 3 复数形态语言会产生错误索引,并导致 ngettext 取 $list[$select] ?? ''(170、179)返回空串。同时这也"恰好"掩盖了发现 1 的 cache 路径——修复发现 2(放行头部)若不做发现 1 的校验,会直接把 RCE 变成默认可达,两处必须一起修。
发现 3(中危功能缺陷):多 MO 叠加语义双向错乱
54:64:var/Typecho/I18n/GetTextMulti.php
foreach ($this->handlers as $handle) {
$string = $handle->translate($string, $count);
if (- 1 != $count) break;
}GetText::translate()的 cache 分支(117:133)命中/未命中都不写$num,$count保持初始null,-1 != null恒真 → 永远在第一个 handler 后 break。实测translate('beta')(仅存在于第 2 个文件)返回原文'beta'。GetText::ngettext()(145:182)任何分支都不写$num,而GetTextMulti::ngettext(75:87)又用-1初始化 → 永不 break → 全部 handler 执行且结果互相覆盖。实测:第 1 个文件命中OUI、第 2 个文件未命中返回默认英文'yes',已命中的翻译被默认值覆盖;两个文件都有时是"最后一个文件赢"(与translate的"第一个文件赢"自相矛盾)。
这正是 I18n::addLang()(插件语言包叠加,140:143)依赖的语义,当前实现下插件/主题语言包在非中文站点上要么不生效、要么被英文默认值覆盖。
建议:统一"首个命中即停"语义。cache 分支命中时置 $num 为索引、未命中置 -1;GetTextMulti 仅在 $count != -1 时采纳结果并 break。
发现 4(低危/健壮性,潜在 DoS):异常 MO 导致未捕获致命错误
- 析构
fclose(null)(190:193):文件不存在时构造器走 short-circuit(73:76)返回,$STREAM为null,对象销毁即抛TypeError。实测复现。触发路径:I18n::addLang()未做file_exists防护时传入缺失路径(Helper::lang()有防护,但插件可直接调用addLang)。 - 截断/损坏 MO 在构造阶段致命(
82:99、219:223):unpack()数据不足返回false→array_shift(false)/fseek(null)→ 未捕获TypeError。实测 8 字节 MO 直接致命;0 字节、错误 magic 且随后被translate的文件同理。由于GetText只在file_exists通过后构造,损坏文件落在语言目录即可让每次前端请求崩溃。
建议:fclose 前判断 is_resource($this->STREAM);构造/建表解析失败时置 short_circuit = true(静默回退原文),而不是让异常冒泡。
发现 5(低危,纵深防御):lang 选项的路径拼接与白名单不足
84:88:var/Widget/Init.php
if ($options->lang && $options->lang != 'zh_CN') {
$dir = ... __TYPECHO_ROOT_DIR__ . '/usr/langs';
I18n::setLang($dir . '/' . $options->lang . '.mo');
}install.phpinstall_get_lang()(31:48)直接信任请求参数/Cookie 的lang,无任何白名单校验即随默认选项写入 DB(255),值含../即可拼出usr/langs/之外路径(受.mo后缀限制)。- 后台
Options\General(210:219):lang输入与checkLang规则仅在count($langs) > 1时才注册;而Form::validate()(167:191)只校验注册过的输入 → 在"语言目录无任何 .mo"(全新安装常态)时,管理员可提交任意lang值绕过白名单直接入库(需管理员权限 + CSRF,风险有限,但应修复)。
建议:lang 在写入与加载两端统一用 basename() + 现有语言包键名白名单约束;Install 流程同样校验。
未发现问题(已确认安全)
- MO 表偏移按
unpack的 1-based 数组语义索引,内部自洽,无越界读写; - 单文件、cache=off 的
translate()(getLangs所用路径)工作正常; I18n::init()缺失文件时静默降级,无直接崩溃。
修复优先级建议
- 发现 1 + 发现 2 一并修复:去掉对 MO 头部内容的
eval(白名单/自研解析),并让 cache 模式正确读入头部,修正多语言复数规则。 - 发现 3:统一"首命中即停",补上
translate/ngettext对$num的赋值。 - 发现 4:
fclose判资源、解析失败静默降级。 - 发现 5:加载/写入/安装三处对
lang做白名单与basename()约束。
测试脚本
请在 `\tools_audit_i18n_tmp' 创建一个 PHP 文件,并且运行下面的 PHP 进行测试:
<?php
/**
* Typecho i18n (var/Typecho/I18n) audit harness — builds real .mo files and
* exercises GetText / GetTextMulti / I18n paths under the local PHP.
*/
declare(strict_types=1);
error_reporting(E_ALL);
ini_set('display_errors', '1');
$root = dirname(__DIR__, 2);
require $root . '/var/Typecho/I18n/GetText.php';
require $root . '/var/Typecho/I18n/GetTextMulti.php';
use Typecho\I18n\GetText;
use Typecho\I18n\GetTextMulti;
/* ---------- tiny MO builder (cache-friendly layout) ---------- */
function build_mo(array $map, string $header = ''): string
{
if ($header !== '') {
$map = ['' => $header] + $map;
}
$magic = pack('V', 0x950412de); // little-endian magic
$n = count($map);
$origStr = '';
foreach (array_keys($map) as $o) {
$origStr .= $o;
}
$transStr = '';
foreach ($map as $t) {
$transStr .= $t;
}
$origBase = 28;
$transBase = 28 + strlen($origStr);
$ot = '';
$tt = '';
$po = 0;
$pt = 0;
foreach ($map as $o => $t) {
$ot .= pack('VV', strlen($o), $origBase + $po);
$po += strlen($o);
$tt .= pack('VV', strlen($t), $transBase + $pt);
$pt += strlen($t);
}
$origTab = 28 + strlen($origStr) + strlen($transStr);
$transTab = $origTab + 8 * $n;
$hdr = $magic . pack('V', 0) . pack('V', $n)
. pack('V', $origTab) . pack('V', $transTab) . pack('V', 0) . pack('V', 0);
return $hdr . $origStr . $transStr . $ot . $tt;
}
function line(string $s): void
{
echo $s . PHP_EOL;
}
$dir = __DIR__;
$results = [];
/* ============================================================
* A. Multi-file fallback semantics of GetTextMulti::translate()
* Intention: string missing in handler#1 should be looked up
* in handler#2 .. N (see GetTextMulti "多个mo文件").
* ============================================================ */
$a = build_mo(['alpha' => 'A_ALPHA']);
$b = build_mo(['beta' => 'B_BETA']);
file_put_contents("$dir/a.mo", $a);
file_put_contents("$dir/b.mo", $b);
$m = new GetTextMulti("$dir/a.mo");
$m->addFile("$dir/b.mo");
$t1 = $m->translate('alpha'); // in first file
$t2 = $m->translate('beta'); // only in second file
$results['A.translate.fallback'] = $t2; // expected 'B_BETA' if fallback works
line('A1 translate(alpha) = ' . var_export($t1, true) . ' (expect A_ALPHA)');
line('A2 translate(beta) = ' . var_export($t2, true) . ' (expect B_BETA if multi-file fallback works)');
unset($m);
/* ============================================================
* B. Order semantics of GetTextMulti::ngettext()
* ============================================================ */
$pluralHeader = "Content-Type: text/plain; charset=UTF-8\nPlural-Forms: nplurals=2; plural=n==1?0:1;\n";
$mapA = ['count' . chr(0) . 'counts' => 'ONE_A' . chr(0) . 'MANY_A'];
$mapB = ['count' . chr(0) . 'counts' => 'ONE_B' . chr(0) . 'MANY_B',
'other' . chr(0) . 'others' => 'OTHER_B'];
$pa = build_mo($mapA, $pluralHeader);
$pb = build_mo($mapB, $pluralHeader);
file_put_contents("$dir/pa.mo", $pa);
file_put_contents("$dir/pb.mo", $pb);
$m = new GetTextMulti("$dir/pa.mo");
$m->addFile("$dir/pb.mo");
$n1 = $m->ngettext('count', 'counts', 1); // present in both
$n2 = $m->ngettext('other', 'others', 2); // only in second
$results['B.ngettext.fallback'] = $n2;
line('B1 ngettext(count,1) = ' . var_export($n1, true) . ' (A or B ?)');
line('B2 ngettext(other,2) = ' . var_export($n2, true) . ' (expect OTHER_B if fallback works)');
unset($m);
/* ---- I. ngettext: hit in first file, miss in second -> result overwritten by default? */
$pa2 = build_mo(['yes' . chr(0) . 'yess' => 'OUI' . chr(0) . 'OUIS'], $pluralHeader);
$pb2 = build_mo([], $pluralHeader); // plugin file without 'yes'
file_put_contents("$dir/pa2.mo", $pa2);
file_put_contents("$dir/pb2.mo", $pb2);
$m = new GetTextMulti("$dir/pa2.mo");
$m->addFile("$dir/pb2.mo");
$i1 = $m->ngettext('yes', 'yess', 1); // first file has it, second misses
$results['I.ngettext.overwrite'] = $i1;
line('I1 ngettext(yes,1) = ' . var_export($i1, true) . ' (expect OUI — first file hit)');
unset($m);
/* ---- H. translate() with enable_cache = false (getLangs equivalent) */
$ha = build_mo(['lang' => 'English']);
file_put_contents("$dir/ha.mo", $ha);
$g = new GetText("$dir/ha.mo", false);
$t = $g->translate('lang', $cnt);
$results['H.translate.cacheoff'] = $t;
line('H1 translate(lang) cache=off = ' . var_export($t, true)
. ' (cnt=' . var_export($cnt, true) . ')');
unset($g);
/* ============================================================
* C. eval() of plural-forms header — code execution probe
* GetText::selectString() eval()s a string taken straight from
* the .mo header after naive str_replace.
* NB: loadTables() skips the "" (msgid) header row when
* enable_cache = true, so the header plural-forms is only used
* when enable_cache = false.
* Payload uses function names WITHOUT letter "n" because every
* literal "n" is replaced by the number argument first.
* ============================================================ */
$evilHeader = "Content-Type: text/plain; charset=UTF-8\n"
. "Plural-Forms: nplurals=2; plural=n==1?0:1; touch('zzz_mo_rce');//\n";
$evil = build_mo(['x' . chr(0) . 'xs' => 'Y' . chr(0) . 'Z'], $evilHeader);
file_put_contents("$dir/evil.mo", $evil);
@unlink("$dir/zzz_mo_rce");
// C1 — default runtime configuration (enable_cache = true, GetTextMulti)
$g = new GetText("$dir/evil.mo", true);
$out = $g->ngettext('x', 'xs', 3, $n);
$created = file_exists("$dir/zzz_mo_rce");
line('C1 cache=ON ngettext() = ' . var_export($out, true)
. ' | header loaded? pluralHeader is private; infer via RCE file: ' . var_export($created, true));
@unlink("$dir/zzz_mo_rce");
unset($g);
// C2 — enable_cache = false (as used by Widget\Options\General::getLangs)
chdir($dir);
$g = new GetText("$dir/evil.mo", false);
try {
$out = $g->ngettext('x', 'xs', 3, $n);
$created = file_exists("$dir/zzz_mo_rce");
line('C2 cache=OFF ngettext() = ' . var_export($out, true)
. ' | RCE file created by eval() ? ' . var_export($created, true));
$results['C.plural.eval'] = $created;
} catch (\Throwable $e) {
line('C2 cache=OFF throws ' . get_class($e) . ': ' . $e->getMessage());
$results['C.plural.eval'] = get_class($e);
}
@unlink("$dir/zzz_mo_rce");
unset($g);
/* ============================================================
* D. Malformed plural-forms -> ParseError (uncaught => fatal/DoS)
* ============================================================ */
$badHeader = "Content-Type: text/plain; charset=UTF-8\nPlural-Forms: nplurals=2; plural=@@@BROKEN@@@;\n";
$bad = build_mo(['x' . chr(0) . 'xs' => 'Y' . chr(0) . 'Z'], $badHeader);
file_put_contents("$dir/bad.mo", $bad);
$g = new GetText("$dir/bad.mo", true);
try {
$g->ngettext('x', 'xs', 3, $n);
line('D1 malformed plural-forms cache=ON : NO exception (header not loaded)');
$results['D.plural.parse'] = false;
} catch (\Throwable $e) {
line('D1 malformed plural-forms cache=ON throws ' . get_class($e) . ': ' . $e->getMessage());
$results['D.plural.parse'] = true;
}
unset($g);
$g = new GetText("$dir/bad.mo", false);
try {
$g->ngettext('x', 'xs', 3, $n);
line('D2 malformed plural-forms cache=OFF : NO exception');
$results['D.plural.parse2'] = false;
} catch (\Throwable $e) {
line('D2 malformed plural-forms cache=OFF throws ' . get_class($e) . ': ' . $e->getMessage());
$results['D.plural.parse2'] = get_class($e);
}
unset($g);
/* ============================================================
* E. Missing-file destructor: fclose(null)
* ============================================================ */
$g = new GetText("$dir/not_exist.mo", true); // short-circuit path
try {
unset($g);
line('E1 destructor on missing file: NO error');
$results['E.destructor'] = false;
} catch (\Throwable $e) {
line('E1 destructor on missing file throws ' . get_class($e) . ': ' . $e->getMessage());
$results['E.destructor'] = true;
}
/* ============================================================
* F. Truncated MO header handling (empty read / unpack)
* ============================================================ */
file_put_contents("$dir/trunc.mo", "\xde\x12\x04\x95\x00\x00\x00\x00");
try {
$g = new GetText("$dir/trunc.mo", true);
$r = $g->translate('x', $cnt);
line('F1 truncated mo translate = ' . var_export($r, true));
$results['F.truncated'] = 'ok';
} catch (\Throwable $e) {
line('F1 truncated mo throws ' . get_class($e) . ': ' . $e->getMessage());
$results['F.truncated'] = get_class($e);
}
unset($g);
/* ============================================================
* G. General setLang semantics / missing main mo file
* ============================================================ */
// replicate Widget\Init composition:
$optionsLang = 'en_US';
$path = $dir . '/langs/' . $optionsLang . '.mo';
line('G1 composed lang path = ' . $path . ' exists=' . var_export(file_exists($path), true));
line('');
line('=== SUMMARY ===');
foreach ($results as $k => $v) {
line(str_pad($k, 28) . ' => ' . var_export($v, true));
}Source: typecho/typecho