Skip to content

Commit e9763e9

Browse files
Marat Zimnurovclaude
andcommitted
Trojan Source: имя FTS уезжало сырым в комментарии — и во весь ftsc
Двунаправленные управляющие символы Unicode (U+061C, U+200E, U+200F, U+202A…U+202E, U+2066…U+2069) места не занимают, а раскладку текста меняют, поэтому файл читается не так, как исполняется: CVE-2021-42574. Прошлый шаг закрыл их в строковых литералах восьми целей flang. Оставались две дыры, и вторая шире первой. ДЫРА ПЕРВАЯ — комментарии flang. Имя FTS уезжало в напечатанный код сырым у всех восьми целей одинаково: шапка файла («Модуль flang: «…»»), документация функции («Функция flang «…»»), подпись параметра, javadoc, `///`, @moduledoc, docstring, комментарий Cargo.toml и .csproj. Комментарий не исполняется — ровно поэтому он и опасен: его читают первым, а проверить исполнением не могут. ДЫРА ВТОРАЯ — весь ftsc. Правила не было НИ В ОДНОМ из восьми бэкендов, и имя правила с U+202E доезжало сырым сразу двумя путями: в комментарий («правило «…»», «свойство «…»») и в строковый литерал (System.out.println прогонщика примеров, `test "…"` ExUnit, имя нарушенного свойства в тексте исключения). Чего это стоило (замерено на этой машине, программа с именем «Спис<U+202E>ание <U+202C>» и фикстура discount с такими же именами): • rustc 1.96 — ОШИБКА сборки: `text_direction_codepoint_in_comment` и `…_in_literal` deny-by-default. Напечатанный крейт не собирался вовсе — ни у flang, ни у ftsc; • elixirc 1.20.2 — ОТКАЗ разбора: «invalid bidirectional formatting character in comment/string». Тоже полный отказ, а не придирка; • gcc 13.3 под -Werror — ошибка на НЕПАРНОМ управляющем в комментарии (-Wbidi-chars=unpaired включён по умолчанию), но парную пару RLO…PDF он пропускает молча: его молчание ничего не доказывает; • javac 25 (-Xlint:all -Werror), CPython 3.12, node 24 — молчат. Это не смягчающее обстоятельство, а отягчающее: ради этого молчания атаку и придумали. ПОЧИНКА. Набор кодовых точек один на все шестнадцать бэкендов обоих компиляторов и вынесен в tools/ftsc/src/bidi.mjs — туда же, откуда бэкенды flang уже берут naming.mjs. Восемь копий набора, разъехавшихся по бэкендам flang, заменены импортом. Форма записи общей быть не может, и их ровно три: `\uXXXX` (Java, C#, Go, Python, JS/TS), `\u{X…}` (Rust, Elixir), байты UTF-8 восьмеричными (C — в C99 узкая строка байт-точна только так). Выбирает форму бэкенд: он один знает, что понимает его компилятор. Место — последний шаг emit(): escapeBidiInFiles снимает сырые управляющие со всего вывода. Почему фильтром, а не заботой в каждом месте склейки: мест, где имя FTS попадает в файл, у одного бэкенда десятки, и правило, записанное в каждом из них, держится ровно до следующего нового места. К этому шагу строковые литералы уже экранированы своей функцией литерала (она знает про кавычки и слэши), поэтому сырым остаётся только текст, который язык не разбирает. Экранирование не удваивается: обратный слэш печатается раньше. Пути файлов фильтр не трогает намеренно — они собраны транслитерацией и состоят из ASCII, а экранирование в имени файла разошлось бы с тем, чего ждут компилятор и Makefile; что сырых там нет, проверяют тесты. ЗНАЧЕНИЕ НЕ ИЗМЕНИЛОСЬ — проверено сборкой, а не рассуждением. Программа со всеми двенадцатью управляющими в имени модуля, имени функции и литерале напечатана во все цели; собранные C (gcc 13.3), Java (javac 25, -Xlint:all -Werror), Elixir (elixirc 1.20.2 / OTP 29, --warnings-as-errors), Rust (rustc 1.96) и Python 3.12 отвечают на вызов по имени с сырыми управляющими и возвращают строку, совпадающую с интерпретатором байт в байт: 45 байт, 22 кодовые точки у всех пяти. На стороне ftsc собранный C печатает имя примера теми же байтами, что стоят в IR (сверено od -c), прогонщик примеров Java и ExUnit Elixir — тоже (пример намеренно завален, чтобы имя было напечатано). ТЕСТЫ — общие, перебором ПО РЕЕСТРУ ЦЕЛЕЙ, а не по списку в тесте: • flang/test/emit-bidi.test.mjs расширен: программа несёт управляющие трижды — в имени модуля, в имени функции и в литерале, — а проверяется ВЕСЬ напечатанный файл и имя файла, а не найденный в файле литерал; • tools/ftsc/test/emit-bidi.test.mjs заведён с нуля: реестр там тоже каталогом (src/targets.mjs), поэтому приём тот же. Источник истины для имён — движок FTS (evaluateUtility), тот самый, которым пользуется сам ftsc; • инструмент проверки общий — tools/ftsc/test/bidi-guard.mjs: правило одно на оба компилятора, значит и способ проверить обязан быть один. Набор кодовых точек там СВОЙ, не импортированный из src/bidi.mjs: иначе выпавшая из набора точка исчезла бы разом и из бэкендов, и из проверки. Тест ловит регресс — проверено подкладыванием девятой цели каждому компилятору: цель, печатающая сырыми, цель, теряющая управляющие при печати, цель, сдвигающая кодовую точку на единицу, и цель, уносящая сырой символ в ИМЯ ФАЙЛА, — краснеют все четыре. Снятие фильтра у flang/go и ftsc/java тоже краснеет; после возврата оба теста зелёные. Попутно: из комментария flang/src/emit/js.mjs убран сырой U+202A — единственный на весь репозиторий. Файл, стерегущий от Trojan Source, сам его и содержал. Контракт бэкенда записан там, где его читают: tools/ftsc/SPEC.md, п. 8 (новый), и таблица «Семантика, которая обязана совпадать у всех слоёв» в flang/SPEC.md. НЕ ПРОВЕРЕНО СБОРКОЙ: Go и C# — тулчейнов go и .NET на машине нет. Для них проверена только печать: `\uXXXX` в комментарии и в литерале, значение то же по разбору. Тесты сборки C# в наборе есть и честно пропускаются. НЕ ТРОНУТО, найдено попутно: tools/ftsvm/src/jit.mjs печатает имя правила в комментарий генерируемого JS (commentText) и в литерал (JSON.stringify) без экранирования двунаправленных. Это третья поверхность печати кода в репозитории — отдельная работа. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P6ehGNnEcrkCK1V5iMHNYi
1 parent 90ecb27 commit e9763e9

22 files changed

Lines changed: 768 additions & 284 deletions

flang/SPEC.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ Pattern := { "kind": "empty" } пустой с
265265
| длина строки | в кодовых точках | `Array.from`, а не единицы UTF-16: иначе кириллица и эмодзи считаются неверно |
266266
| `к строке` от признака | `да` / `нет` | поверхность языка русская; кодогенераторы обязаны повторять, а не печатать `true` |
267267
| `к строке` от `ничто` | `ничто` | там же |
268+
| двунаправленные управляющие Unicode в напечатанном коде | сырыми не выходят ни из одной цели — ни в литерале, ни в комментарии; форма своя у языка (`\uXXXX`, `\u{X…}`, восьмеричные байты UTF-8 в C), значение то же | «Trojan Source» (CVE-2021-42574): файл читается не так, как исполняется. Имя FTS уезжает в комментарий у всех восьми целей, а rustc и elixirc такой файл вообще не собирают. Набор — `tools/ftsc/src/bidi.mjs`, фильтр — последний шаг `emit()`, проверка — `flang/test/emit-bidi.test.mjs` перебором по реестру целей |
268269

269270
## 6. Слои реализации
270271

flang/src/emit/c.mjs

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
import { readFileSync } from "node:fs"
7070

7171
import { canonicalBuiltinName, flangError, hasBuiltin } from "../builtins.mjs"
72+
import { BIDI_CONTROLS, escapeBidiInFiles, escapeBidiOctalBytes } from "../../../tools/ftsc/src/bidi.mjs"
7273
import { createNamer, pascal, snake } from "../../../tools/ftsc/src/naming.mjs"
7374

7475
/* ═══════════════════════════════════════════════════════════════════════════
@@ -325,24 +326,6 @@ function stronglyConnected(names, edges) {
325326

326327
/* ═══════════════════════════ литералы C ═══════════════════════════ */
327328

328-
/**
329-
* Двунаправленные управляющие символы Unicode. Раскладку текста они меняют, а
330-
* места не занимают, поэтому исходник с ними читается не так, как исполняется —
331-
* это «Trojan Source» (CVE-2021-42574). Компиляторы такое ловят: GCC 13 даёт
332-
* -Wbidi-chars (в -Werror это ошибка), rustc — deny-by-default
333-
* text_direction_codepoint_in_literal. Программе flang они попадают в литерал
334-
* законно: таблица блоков в лексере (flang/self/lexer.flang) перечисляет весь
335-
* блок U+2000…U+207F подряд, и одиннадцать из них — как раз эти.
336-
*
337-
* Поэтому печатать их сырыми нельзя ни в одном бэкенде: набор общий, а форма
338-
* экранирования у каждого языка своя.
339-
*/
340-
const BIDI_CONTROLS = new Set([
341-
0x061c /* ALM */, 0x200e /* LRM */, 0x200f /* RLM */, 0x202a /* LRE */, 0x202b /* RLE */,
342-
0x202c /* PDF */, 0x202d /* LRO */, 0x202e /* RLO */, 0x2066 /* LRI */, 0x2067 /* RLI */,
343-
0x2068 /* FSI */, 0x2069 /* PDI */,
344-
])
345-
346329
/**
347330
* Строковый литерал C. Кириллица печатается как есть (UTF-8 в исходнике —
348331
* ровно то, ради чего этот язык затевался: имена в коде обязаны читаться), а
@@ -533,7 +516,15 @@ export function emitC(program, options = {}) {
533516
})
534517
}
535518
files.push({ path: "Makefile", content: renderMakefile(file, options.cli !== false) })
536-
return { files }
519+
/* Последний шаг — снять сырые двунаправленные управляющие со всего вывода
520+
(bidi.mjs). Литерал их уже экранировал сам, но имя FTS уезжает ещё и в
521+
комментарии — в шапку файла, в описание функции, в подпись поля, — а
522+
комментарий читают первым и проверить исполнением не могут. gcc 13 под
523+
-Werror останавливает сборку на НЕПАРНОМ управляющем (в комментарии тоже),
524+
а парную пару RLO…PDF пропускает молча: его молчание не доказательство.
525+
Форма для C одна и в литерале, и в комментарии — байты UTF-8
526+
восьмеричными: две записи в одном языке пришлось бы держать в голове. */
527+
return { files: escapeBidiInFiles(files, escapeBidiOctalBytes) }
537528
}
538529

539530
function banner(moduleName, what) {

flang/src/emit/csharp.mjs

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
import { readFileSync } from "node:fs"
8484

8585
import { canonicalBuiltinName, flangError, hasBuiltin } from "../builtins.mjs"
86+
import { BIDI_CONTROLS, escapeBidiInFiles, escapeBidiUnicode4 } from "../../../tools/ftsc/src/bidi.mjs"
8687
import { camel, pascal } from "../../../tools/ftsc/src/naming.mjs"
8788

8889
/* ═══════════════════════════════════════════════════════════════════════════
@@ -388,23 +389,6 @@ function stronglyConnected(names, edges) {
388389

389390
/* ═══════════════════════════ литералы C# ═══════════════════════════ */
390391

391-
/**
392-
* Двунаправленные управляющие символы Unicode: раскладку текста меняют, места
393-
* не занимают, и потому исходник с ними читается не так, как исполняется —
394-
* «Trojan Source» (CVE-2021-42574). Ругается ли на них Roslyn, здесь не
395-
* проверено: тулчейна .NET на машине не было. Рассчитывать на его молчание, как
396-
* и на его крик, всё равно нельзя — набор общий с остальными бэкендами, и там
397-
* это уже не предупреждение: в C и Rust прямая ошибка сборки, в Elixir отказ
398-
* разбора. В литерал они попадают законно: таблица блоков лексера
399-
* (flang/self/lexer.flang) перечисляет весь блок U+2000…U+207F подряд, и
400-
* одиннадцать из них — как раз эти.
401-
*/
402-
const BIDI_CONTROLS = new Set([
403-
0x061c /* ALM */, 0x200e /* LRM */, 0x200f /* RLM */, 0x202a /* LRE */, 0x202b /* RLE */,
404-
0x202c /* PDF */, 0x202d /* LRO */, 0x202e /* RLO */, 0x2066 /* LRI */, 0x2067 /* RLI */,
405-
0x2068 /* FSI */, 0x2069 /* PDI */,
406-
])
407-
408392
/**
409393
* Строковый литерал C#.
410394
*
@@ -607,7 +591,13 @@ export function emitCsharp(program, options = {}) {
607591
}
608592
files.push({ path: "flang.csproj", content: renderProject(moduleName, options.cli !== false) })
609593
files.push({ path: "Makefile", content: renderMakefile(className, options.cli !== false) })
610-
return { files }
594+
/* Последний шаг — снять сырые двунаправленные управляющие со всего вывода
595+
(bidi.mjs). Литерал их уже экранировал сам, но имя FTS уезжает ещё и в
596+
комментарии — в шапку файла, в `///`-документацию и в комментарий .csproj,
597+
— а комментарий читают первым и проверить исполнением не могут. Как ведёт
598+
себя Roslyn, здесь не проверено: тулчейна .NET на машине нет, — и его
599+
молчание всё равно не было бы доказательством. Форма C# — `\uXXXX`. */
600+
return { files: escapeBidiInFiles(files, escapeBidiUnicode4) }
611601
}
612602

613603
function banner(moduleName, what) {

flang/src/emit/elixir.mjs

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@
9999
import { readFileSync } from "node:fs"
100100

101101
import { canonicalBuiltinName, flangError, hasBuiltin } from "../builtins.mjs"
102+
import { BIDI_CONTROLS, escapeBidiBraced, escapeBidiInFiles } from "../../../tools/ftsc/src/bidi.mjs"
102103
import { pascal, snake } from "../../../tools/ftsc/src/naming.mjs"
103104

104105
/* ═══════════════════════════════════════════════════════════════════════════
@@ -385,22 +386,6 @@ function stronglyConnected(names, edges) {
385386

386387
/* ═══════════════════════════ литералы Elixir ═══════════════════════════ */
387388

388-
/**
389-
* Двунаправленные управляющие символы Unicode: раскладку текста меняют, места
390-
* не занимают, и потому исходник с ними читается не так, как исполняется —
391-
* «Trojan Source» (CVE-2021-42574). Для Elixir это не придирка, а отказ: разбор
392-
* падает с «invalid bidirectional formatting character in string», и
393-
* напечатанный модуль не собирается вовсе — ни под `--warnings-as-errors`, ни
394-
* без него. Набор общий с остальными бэкендами (в C и Rust — тоже отказ
395-
* сборки), а в литерал они попадают законно: таблица блоков лексера
396-
* (flang/self/lexer.flang) перечисляет весь блок U+2000…U+207F подряд.
397-
*/
398-
const BIDI_CONTROLS = new Set([
399-
0x061c /* ALM */, 0x200e /* LRM */, 0x200f /* RLM */, 0x202a /* LRE */, 0x202b /* RLE */,
400-
0x202c /* PDF */, 0x202d /* LRO */, 0x202e /* RLO */, 0x2066 /* LRI */, 0x2067 /* RLI */,
401-
0x2068 /* FSI */, 0x2069 /* PDI */,
402-
])
403-
404389
/**
405390
* Строковый литерал Elixir.
406391
*
@@ -631,7 +616,14 @@ export function emitElixir(program, options = {}) {
631616
})
632617
}
633618
files.push({ path: "Makefile", content: renderMakefile(alias, file, options.cli !== false) })
634-
return { files }
619+
/* Последний шаг — снять сырые двунаправленные управляющие со всего вывода
620+
(bidi.mjs). Литерал их уже экранировал сам, но имя FTS уезжает ещё и в
621+
комментарии и в @moduledoc/@doc. Для elixirc это отказ разбора и там, и там
622+
(«invalid bidirectional formatting character in comment/string»): до этого
623+
фильтра напечатанный модуль с таким именем не собирался вовсе. Форма
624+
Elixir — `\u{X…}`, та же, что в литерале; @doc остаётся строкой с теми же
625+
байтами. */
626+
return { files: escapeBidiInFiles(files, escapeBidiBraced) }
635627
}
636628

637629
/**

flang/src/emit/go.mjs

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@
9494
import { readFileSync } from "node:fs"
9595

9696
import { canonicalBuiltinName, flangError, hasBuiltin } from "../builtins.mjs"
97+
import { BIDI_CONTROLS, escapeBidiInFiles, escapeBidiUnicode4 } from "../../../tools/ftsc/src/bidi.mjs"
9798
import { camel, createNamer, pascal, snake } from "../../../tools/ftsc/src/naming.mjs"
9899

99100
/* ═══════════════════════════════════════════════════════════════════════════
@@ -372,21 +373,13 @@ function stronglyConnected(names, edges) {
372373
* По символам, а не по байтам: файл записывается в UTF-8, и «Длина»,
373374
* разобранная на байты и собранная обратно как символы, превратилась бы в
374375
* двойную кодировку — имя перестало бы совпадать с именем в интерпретаторе.
376+
*
377+
* Двунаправленные управляющие (набор — bidi.mjs, общий на все бэкенды обоих
378+
* компиляторов) уезжают в `\uXXXX`: go vet за сырые не ругается, но набор общий
379+
* с C, Rust и Elixir, где это прямая ошибка сборки. Ровно четыре цифры, поэтому
380+
* цифра, стоящая в строке следом, к экранированию не приклеится; кодовая точка
381+
* и байты те же — меняется только запись.
375382
*/
376-
/**
377-
* Двунаправленные управляющие символы Unicode: раскладку текста меняют, места
378-
* не занимают, и потому исходник с ними читается не так, как исполняется —
379-
* «Trojan Source» (CVE-2021-42574). go vet за это не ругается, но печатать их
380-
* сырыми всё равно нельзя: файл с ними нечитаем в ревью, а набор общий с
381-
* бэкендами C и Rust, где это прямая ошибка сборки. В литерал они попадают
382-
* законно: таблица блоков лексера перечисляет весь блок U+2000…U+207F.
383-
*/
384-
const BIDI_CONTROLS = new Set([
385-
0x061c /* ALM */, 0x200e /* LRM */, 0x200f /* RLM */, 0x202a /* LRE */, 0x202b /* RLE */,
386-
0x202c /* PDF */, 0x202d /* LRO */, 0x202e /* RLO */, 0x2066 /* LRI */, 0x2067 /* RLI */,
387-
0x2068 /* FSI */, 0x2069 /* PDI */,
388-
])
389-
390383
function gostring(value) {
391384
let result = '"'
392385
for (const character of String(value)) {
@@ -546,7 +539,13 @@ export function emitGo(program, options = {}) {
546539
})
547540
}
548541
files.push({ path: "Makefile", content: renderMakefile(options.cli !== false) })
549-
return { files }
542+
/* Последний шаг — снять сырые двунаправленные управляющие со всего вывода
543+
(bidi.mjs). Литерал их уже экранировал сам, но имя FTS уезжает ещё и в
544+
комментарии — в шапку файла и в строку «Ident — функция flang «…»», — а
545+
комментарий читают первым и проверить исполнением не могут. go vet о таком
546+
не предупреждает: молчание тулчейна здесь и есть беда, а не оправдание.
547+
Форма Go — `\uXXXX`, та же, что в литерале. */
548+
return { files: escapeBidiInFiles(files, escapeBidiUnicode4) }
550549
}
551550

552551
function banner(moduleName, what) {

0 commit comments

Comments
 (0)