Предыстория

За последние два года мне несколько раз пришлось восстанавливать завёрнутый код Oracle версий 7.2-9.2:

  • один раз - в рамках рабочей задачи;

  • один раз - чтобы вернуть наш собственный потерянный пакет;

  • пару раз - по просьбе коллег из финтеха: древний код неправильно считал налог, и надо было разобраться почему.

Имевшаяся утилита REWRAP 1993 года, рассчитанная на PL/SQL 2.2, помогла несильно: код нужно было довести до уровня, пригодного для отладки. Так я погрузился в тему unwrapping-а, и эта статья - сводка того, что удалось выяснить о формате V1 в ходе создания unwrapper-а.

Введение

В Oracle 7.2–9.2 утилита WRAP не шифрует исходный текст. Она прогоняет его через фронтенд компилятора PL/SQL и записывает в файл синтаксическое дерево DIANA - промежуточное представление, которое база затем достраивает и компилирует. Поэтому формат V1 обратим: имена и литералы лежат в открытом лексиконе, а структура программы - в шести числовых таблицах.

Статья разбирает, что такое DIANA, через какие слои проходит код на пути в wrapped-файл, как устроен этот файл и что в нём теряется. Формат V1 охватывает PL/SQL 2.2–9.2, то есть Oracle 7.2, 7.3, 8.0, 8i и 9i. Формат V2 (Oracle 10g и новее) с ним не связан: там хранится сжатый текст без дерева.

Цифры в статье получены дампом таблиц эталонных юнитов - wrapped-файлов из дистрибутивов Oracle и синтетических примеров, завёрнутых WRAP разных версий: 332 юнита V1, 16 625 узлов. Внешние источники - документация Oracle 9.2, справочник DIANA и доклад Пита Финнигана на Black Hat 2006. Распаковка формата V2 (Oracle 10g и новее) реализована в репозитории oracle-plsql-unwrap.

DIANA: дерево с атрибутами

DIANA (Descriptive Intermediate Attributed Notation for Ada) - промежуточный язык компиляторов Ada: программа хранится как дерево узлов с именованными атрибутами. Её спроектировали группы Герхарда Гооса (Карлсруэ) и Уильяма Вульфа (Карнеги–Меллон) в 1981 году (FOLDOC). Структура дерева задана на метанотации IDL (Interface Description Language), а физическое представление скрыто за операциями типа (справочник DIANA, ред. 3, 1983).

PL/SQL построен на Ada и использует свой вариант DIANA, тоже описанный на IDL. По документации Oracle 9.2, база хранит и DIANA, и m-code: по DIANA компилируются зависимые модули, m-code просто исполняется. Спецификация пакета, типа, отдельная процедура или анонимный блок ограничены 2^26 узлами DIANA; при превышении компилятор выдаёт ошибку вроде PLS-00123.

Узлы

Таблица раскладок, извлечённая из IDL-дескриптора WRAP 9.2.0.4, содержит 300 кодов узлов; в 9.x атрибуты есть у 230 из них. Префикс имени говорит о семействе узла. Старые имена обрезаны до 8 символов (D_COMP_U, DI_PACKA), узлы, добавленные в 8i–9i, - длиннее (D_SQL_STMT, DI_BULK_ITER).

Префикс

Семейство

Кодов

Примеры

D_

конструкции языка

166

D_COMP_U - единица компиляции, D_P_SPEC - спецификация пакета, D_BLOCK, D_ASSIGN, D_F_CALL

DI_

идентификаторы: определения и использования

35

DI_PACKA, DI_PROC, DI_VAR, DI_U_NAM - использованное имя

DS_

последовательности

23

DS_DECL - объявления, DS_STM - операторы, DS_PARAM

Q_, QI_, QS_

статический SQL

75

Q_SELECT, Q_INSERT, Q_SQL_ST, QI_CURSO

Атрибуты

В исходной DIANA четыре вида атрибутов: структурные (as_), лексические (lx_), семантические (sm_) и атрибуты кода (cd_). PL/SQL сохраняет это деление со своими префиксами: в таблице раскладок 184 имени атрибутов. Последняя колонка - доля ненулевых значений в 332 юнитах сэмплов: она показывает, какие атрибуты WRAP вообще заполняет.

Префикс

Вид

Имён

Примеры

Ненулевых в wrapped-файлах

A_

структурный: ссылка на узел или флаг

84

A_NAME, A_EXP1, A_UP - ссылка на родителя

63%

AS_

структурный: последовательность

28

AS_DECL1, AS_STM, AS_LIST

72%

L_

лексический: номер лексемы

7

L_SYMREP - имя, L_NUMREP - число, L_Q_HINT - хинт

54%

S_

семантический

47

S_EXP_TY - тип выражения, S_DEFN_PRIVATE - определение имени

5,1%

SS_

семантическая последовательность

10

SS_SQL, SS_BINDS

0%

C_

кодогенерация

8

C_OFFSET, C_FIXUP, C_ENTRY_PT

0,2%

Структурные и лексические атрибуты заполнены, семантические и атрибуты кода практически пусты. Из семантических встречаются лишь связи, которые парсер знает сам: S_LAYER, S_FIRST, S_T_SPEC, S_RECORD. Исходный текст можно восстановить именно из структурных атрибутов: авторы DIANA закладывали тесное соответствие между синтаксисом языка и этими атрибутами.

Где DIANA живёт в базе

Скомпилированный модуль хранится в таблицах SYS IDL_UB1$, IDL_UB2$, IDL_SB4$ и IDL_CHAR$. Суффиксы - типы элементов IDL: беззнаковые 1 и 2 байта, знаковые 4 байта, символы. Колонка PART разделяет содержимое: 0 - DIANA, 1 - переносимый p-code (m-code), 2 - машинно-зависимый p-code; так сказано в комментарии sql.bsq, на который ссылается Финниган.

Из PL/SQL дерево читают пакеты PIDL и DIANA (скрипты pipidl.sql, pidian.sql) и утилиты DIUTIL. Имена узлов и атрибутов из pidian.sql совпадают с теми, что зашиты в WRAP. По наблюдению Финнигана, DIANA тел пакетов в IDL-таблицах не сохраняется, и через эти API тела процедур не достать.

Где WRAP встраивается в компиляцию

WRAP - это лексер и парсер PL/SQL, которые останавливаются перед семантическим анализом. Файл .plb содержит то дерево, которое база построила бы сама после разбора исходника. При загрузке она пропускает разбор и продолжает с семантики.

В документации Oracle 9.2 тело wrapped-юнита названо промежуточной формой объектного кода (Wrap Utility, 9.2), а Финниган прямо называет WRAP фронтендом компилятора PL/SQL. Из этого следуют три вещи:

  • WRAP не видит базу. Он не разрешает внешние ссылки, поэтому опечатка в имени таблицы проявится только при компиляции .plb. Синтаксические ошибки он сообщает сам.

  • Семантические атрибуты в файле пусты. Это видно по замеру в таблице атрибутов: S_ заполнены на 5,1%, SS_ - на 0%, C_ - на 0,2%. Имена в DI_U_NAM не связаны с определениями: S_DEFN_PRIVATE ненулевой в 2 случаях из 5 107.

  • Файл совместим вверх, но не вниз. Юнит WRAP 8.1.5 загружается в 8.1.6, обратное невозможно. Вероятная причина - раскладки узлов: в новой версии DIANA у узлов появляются атрибуты, которых старый компилятор не знает.

Формат wrapped-файла V1

Wrapped-юнит V1 - обычный текст: скопированный заголовок CREATE, служебная шапка, лексикон и шесть таблиц дерева в шестнадцатеричной записи. Схема показывает файл из разбора ниже (пакет va, 59 узлов) в порядке строк, как его читает парсер распаковщика.

Шапка

WRAP копирует оператор CREATE до конца имени юнита как есть - с регистром, схемой, кавычками, переводами строк и комментариями - и дописывает wrapped. У тела типа копия идёт до IS или AS, поэтому пробелы и комментарии перед ними тоже попадают в заголовок. Дерево заголовка не хранит: CREATE и CREATE OR REPLACE, имя со схемой и без дают одинаковые таблицы.

Дальше идут строка 0 и строки abcd: 16 штук в PL/SQL 2.x–8.1.5 и 15 начиная с 8.1.6, где на месте шестнадцатой стоит число (в сэмплах 1 или 3). Следом - код вида юнита в hex, он совпадает с типом объекта в словаре: 7 - процедура, 8 - функция, 9 - пакет, b - тело пакета, d - тип, e - тело типа, 16 - библиотека.

Затем идёт версия DIANA - семь шестнадцатеричных цифр. Это версия PL/SQL, а не Oracle (PL/SQL 2.2 - это Oracle 7.2, 2.3 - 7.3, дальше номера совпадают). И это не релиз самого WRAP: утилита пишет последнюю версию DIANA, которую знает.

WRAP (релиз PL/SQL)

Строка версии в юните

9.2.0.4

9200000

9.0.1

9000000

8.1.6, 8.1.7

8106000

8.1.5 (Oracle8i для Windows NT)

8105000

8.0.5

8003000

2.3.3.0.0, 2.3.4

200f000

2.3.2.3.0 для Windows NT

95a025ff

2.2.2.3.1 для Windows NT

952825ff

Слова на 25ff - не версия, а след ошибки импорта. WRAP для Windows PL/SQL 2.2 и 2.3.2 импортирует переменную версии как функцию и читает первые четыре байта переходника jmp [slot]: FF 25 и младшие байты адреса слота. Настоящая версия этих сборок - 200f000, и раскладка атрибутов у них та же, что у 2.3.4. После версии идут постоянные строки 1, 4, 0.

Лексикон

Лексикон - список всех различных слов юнита: имён, литералов, знаков операций и ключевых слов, которые лексер читает как идентификаторы (PACKAGE, TYPE, VARRAY). Каждое слово записано один раз, в порядке первого появления; номер 0 - пустая лексема. Первая строка - число лексем в hex, последняя - 0.

  • PL/SQL 2.x и 8.0: строка <длина в hex> <текст>:; длинная лексема продолжается на следующих строках, пока не наберётся длина.

  • PL/SQL 8.1.5 и новее: маркер 2 :e:, затем строки 1<текст>:; строка, оканчивающаяся на +, продолжается следующей.

  • Экранирование: перевод строки внутри лексемы - :n, двоеточие - ::, возврат каретки записан как есть.

Пример строк лексикона: PL/SQL 2.3.4 и 9.2
2.3.4:  12 SELECT_DELETE_DEMO:      ← 0x12 = 18 символов
        4 P_ID:
9.2:    1SELECT empno, ename:n      from emp:n     where deptno = p:n     order by ena+
        1me:                        ← продолжение лексемы после +

Шесть таблиц дерева

Каждая таблица начинается с числа позиций и строки-маркера 2 или 4, затем идут значения в hex через пробел. Запись :N v означает N повторов значения v. Позиция 0 значит «нет значения», поэтому узлы, атрибуты и элементы списков нумеруются с 1.

№

Таблица

Индекс

Значение

Маркер: 2.x–8.1.5 / 8.1.6 / 9.x

1

Node Types

номер узла

код типа узла (D_COMP_U = 23)

2 / 2 / 2

2

Attribute References

номер узла

позиция первого атрибута в таблице 5

2 / 2 / 2

3

Columns

номер узла

колонка в исходном тексте

2 / 2 / 2

4

Lines

номер узла

строка в исходном тексте

2 / 2 / 4

5

Attributes

позиция

значения атрибутов всех узлов блоками

2 / 4 / 4

6

AS Lists

позиция

список: длина, затем номера узлов

2 / 4 / 4

Маркер, по всей видимости, - ширина элемента в байтах. Парсер проверяет только, что это 2 или 4, а во всех 332 юнитах сэмплов значения зависят только от версии.

Узел - это номер, по которому из таблиц 1–4 берутся тип, начало блока атрибутов и позиция в тексте. Атрибут k узла лежит в таблице 5 по позиции «ссылка + k − 1». Список в таблице 6 записан как длина и столько же номеров узлов вслед за ней.

Число атрибутов узла в файле не записано. Его знает только IDL-описание фронтенда той версии, что писала файл; лишний атрибут окажется атрибутом соседнего узла. Блоки лежат не в порядке узлов, а между ними бывают ничьи слоты со случайными значениями.

До 8.1.6 ссылка на блок атрибутов 16-битная, а её старшие разряды спрятаны в коде типа узла. Если код ≥ 0x1000, то тип - это код mod 0x1000, а к ссылке добавляется (код div 0x1000) × 0x10000.

Эпилог и хвост

После таблиц снова идут 1, 4, 0 и номер корневого узла; корень обязан иметь тип D_COMP_U. Дальше WRAP пишет хвост, который распаковщик не читает: строки 0, 1, 14, два счётчика, 0x14 = 20 чисел и тройки чисел. Первое число тройки в сэмплах - номер определяющего идентификатора: пакета, подпрограммы, параметра, переменной, курсора или индекса цикла. Юнит заканчивается строкой /.

Слои преобразования

На пути в .plb текст проходит семь шагов, и каждый что-то теряет. Самое важное для восстановления: семантический шаг пропущен, поэтому в файле стоит дерево, близкое к синтаксису, а не откомпилированная программа. То, что теряется на шагах 3–5, часто оставляет косвенные следы, и распаковщик их ищет.

Лексер: что остаётся от текста

  • Регистр. Имена без кавычек попадают в лексикон в верхнем регистре: пакет va записан как VA. Имя в кавычках сохраняет регистр, а сами кавычки становятся флагом узла-идентификатора (четвёртый атрибут, значения 1 или 5).

  • Единственность и порядок. Каждая лексема записана один раз в порядке первого появления: NUMBER встречается в примере трижды, а в лексиконе стоит однажды, под номером 8. Потому даже порядок слов в восстановленном тексте влияет на совпадение таблиц.

  • Ключевые слова. Зарезервированные слова (BEGIN, END, IS, AS, WITH, IN) следа не оставляют. Слова, которые лексер читает как идентификаторы (PACKAGE, TYPE, VARRAY, EXTERNAL, LANGUAGE, NAME), становятся узлами DI_U_NAM, на которые не ссылается ни один атрибут. Эти «сироты» - главный источник косвенных следов.

  • Комментарии. Исчезают, кроме трёх случаев: комментарий в заголовке, хинт оптимизатора (лексема L_Q_HINT) и SQL, сохранённый текстом. До 9.0 WRAP теряет символ сразу после /*+, и распаковщик ставит там пробел.

Парсер: дерево и его нумерация

Узлы нумеруются в порядке создания: парсер сначала строит детей, потом родителя. В примере корень D_COMP_U - узел 58 из 59. Каждый узел хранит строку и колонку своего токена, поэтому wrapped-код может сообщать номера строк исходника.

Блоки атрибутов выделяются не в порядке узлов, и это тоже след. WRAP до PL/SQL 9.0 после IS читает один токен вперёд, а после AS - нет. Узел забегающего токена получает блок раньше имени пакета, и по этому следу распаковщик выбирает IS или AS.

Нормализация: сахар становится каноном

Фронтенд пишет в дерево не слова исходника, а их каноническую форму, и для этого иногда добавляет свои лексемы. Распаковщик должен узнать канон и вернуть исходную запись.

В исходнике

В дереве

По чему видна форма

VARRAY(10) OF NUMBER

D_ARRAY с флагом 2 и диапазоном 1..10; лексему 1 добавил парсер

VARYING ARRAY оставляет узел-сироту ARRAY перед литералом

TABLE OF NUMBER

D_ARRAY с флагом 1 и индексом BINARY_INTEGER, которого в тексте нет

флаг 1; у INDEX BY флаг 0

CASE без ELSE

ветка ELSE с RAISE CASE_NOT_FOUND

имя исключения помечено флагом 64 «создано парсером»

TRIM(LEADING x FROM y)

вызов SYS$STANDARD_TRIM с кодом режима (5, 6, 7)

имя с пробелом нельзя написать в тексте; BOTH - по узлу слова

CHR(x USING NCHAR_CS)

вызов SYS$STANDARD_CHR

то же имя с пробелом

a = b, a MOD b

вызов функции-оператора; имена =, < лежат в лексиконе

a MOD b и MOD(a, b) различает тип списка аргументов

Статический SQL

По умолчанию WRAP разбирает каждый SQL-оператор на узлы под Q_SQL_ST, и распаковщик собирает его заново в своей раскладке. WRAP 9.2 с ключом edebug=wrap_new_sql хранит оператор узлом D_SQL_STMT с исходным текстом в лексиконе, вместе с комментариями. Операторы курсора OPEN, FETCH, CLOSE в обоих режимах остаются узлами.

Ключ описан в руководстве PL/SQL 9.2: он включает поддержку всего синтаксиса SQL и поэтому оставляет SQL открытым текстом. В корпусе из 4017 юнитов таких 7; ещё 1429 содержат разобранный SQL, 2581 обходятся без статического SQL.

Сериализация

Дерево целиком раскладывается по таблицам из предыдущего раздела, и новых потерь тут нет. Но число атрибутов узла становится неявным, а между блоками появляются ничьи слоты: за узлом без атрибутов их всегда два. WRAP оставляет в них произвольные значения, так что два файла с одним деревом могут различаться байтами.

Разбор примера: пакет va

Спецификация пакета из тестового набора (WRAP 9.2.0.4) занимает семь строк, а в .plb превращается в 18 лексем, 59 узлов, 251 слот атрибутов и 20 позиций списков - 146 строк. Распаковщик возвращает эталонный текст байт в байт.

Текст

Исходный текст пакета va (8 строк)
CREATE OR REPLACE PACKAGE va IS
   TYPE V1 IS VARRAY( 10 ) OF NUMBER;
   TYPE V2 IS VARYING ARRAY( 5 ) OF VARCHAR2( 10 );
   TYPE V3 IS VARRAY( 3 ) OF NUMBER NOT NULL;
   TYPE T1 IS TABLE OF NUMBER;
   X V1;
END;
/

Это эталон сэмпла и одновременно вывод распаковщика. Раскладка в нём каноническая - отступ в три пробела, пробелы внутри скобок, - а WRAP той же версии строит из него то же дерево DIANA.

Wrapped-файл

Wrapped-файл пакета va целиком (146 строк)
CREATE OR REPLACE PACKAGE va wrapped 
0
abcd
abcd
abcd
abcd
abcd
abcd
abcd
abcd
abcd
abcd
abcd
abcd
abcd
abcd
abcd
3
9
9200000
1
4
0 
12
2 :e:
1PACKAGE:
1VA:
1TYPE:
1V1:
1VARRAY:
11:
110:
1NUMBER:
1V2:
1VARYING:
1ARRAY:
15:
1VARCHAR2:
1V3:
13:
1T1:
1BINARY_INTEGER:
1X:
0

0
0
3c
2
0 a0 97 a0 9d a0 :2 51 63
a8 a0 1c c 77 a0 9d :2 a0
:2 51 63 a8 a0 51 a5 1c c
77 a0 9d a0 :2 51 63 a8 a0
1c c 77 a0 9d a0 1c a0
40 a8 c 77 a3 a0 1c 81
b0 :2 aa 59 58 1d 17 b5 
3c
2
0 3 7 11 39 19 1d 20
23 27 28 2c 34 15 40 70
48 4c 50 53 56 5a 5b 5f
62 63 6b 44 77 9f 7f 83
86 89 8d 8e 92 9a 7b a6
c5 ae b2 ba be bf c0 aa
e1 d0 d4 dc cf cc e8 ea
ed f0 f1 fa 
3c
2
0 1 9 3 8 e :4 14 :2 1c
e :2 3 8 e 16 :4 1b 22 2b
2a 22 e :2 3 8 e :4 14 :2 1b
e :2 3 8 :2 17 :4 e :2 3 :3 5 3
:7 1 
3c
4
0 :2 1 :b 2 :e 3
:b 4 :9 5 :5 6 :7 1

fc
4
:3 0 1 :3 0 2
:6 0 1 :2 0 3
:3 0 4 0 c
35 5 :3 0 6
:2 0 7 :2 0 6
7 :2 0 3 8
:3 0 a :7 0 9
b :2 0 2 4
c 4 :4 0 3
:3 0 f 0 1a
35 a :3 0 b
:3 0 6 :2 0 c
:2 0 12 13 :2 0
5 d :3 0 7
:2 0 7 16 18
:6 0 15 19 :2 0
2 9 1a f
:4 0 3 :3 0 1d
0 25 35 5
:3 0 6 :2 0 f
:2 0 1f 20 :2 0
9 8 :3 0 23
0 2 :5 0 22
24 :2 0 2 e
25 1d :4 0 3
:3 0 28 0 2e
35 8 :3 0 29
:7 0 11 :3 0 2b
b 2d 2a :2 0
1 10 2e 28
:4 0 f 37 0
d 4 :3 0 31
:7 0 34 32 0
35 0 12 :7 0
37 35 36 38
2 37 3a 0
39 38 3b :8 0

15
4
:3 0 1 8 1
14 1 17 1
21 1 2c 1
30 5 d 1b
26 2f 33 
1
4
0 
3a
0
1
14
1
6
0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0
0 0 0 0 
28 1 0
4 1 0
f 1 0
1d 1 0
2 0 1
30 1 0
0

/

Вторая команда файла, CREATE OR REPLACE TYPE vt AS VARRAY(20) OF NUMBER;, осталась в .plb открытой. По документации 9.2 WRAP заворачивает из типов только объектные (CREATE TYPE … OBJECT) и тела типов, а остальные команды передаёт как есть.

Дерево

Нумерация идёт снизу вверх: литералы диапазона получили номера 6 и 7, сам диапазон - 8, корень - 58. Позиции взяты из исходника, который видел WRAP: там было VARRAY(10) без пробелов, поэтому у NUMBER записана колонка 28, а в каноническом тексте он стоит правее.

Один узел вручную

Расшифруем узел 8 - диапазон 1..10 из VARRAY(10):

  • Типы узлов, позиция 8: 63 = 99, то есть D_RANGE. В строке 0 a0 97 a0 9d a0 :2 51 63 запись :2 51 занимает позиции 6 и 7.

  • Ссылки на атрибуты, позиция 8: 23 = 35.

  • Колонки и строки, позиция 8: колонка 20, строка 2.

  • Раскладка D_RANGE для 9.x - четыре атрибута: A_EXP1, A_EXP2, S_BASE_T, S_LENGTH_SEMANTICS. В таблице атрибутов на позициях 35–38 стоят 6, 7, 0, 0: границы - узлы 6 и 7, семантика пуста.

  • Узлы 6 и 7 - D_NUMERI с L_NUMREP, равным лексемам 6 и 7, то есть 1 и 10.

  • Родитель - узел 9 DS_D_RAN с AS_LIST = 3. В таблице списков на позиции 3 стоят длина 1 и элемент 8.

Второй пример: процедура с SELECT INTO, IF и циклом

На пакете va видны только объявления. Вот процедура с SELECT … INTO, IF и циклом:

Исходный текст процедуры bonus (11 строк)
CREATE OR REPLACE PROCEDURE bonus(p_empno IN NUMBER) AS
  v_sal NUMBER;
BEGIN
  SELECT sal INTO v_sal FROM emp WHERE empno = p_empno;
  IF v_sal < 1000 THEN
    FOR i IN 1 .. 3 LOOP
      v_sal := v_sal * 2;
    END LOOP;
  END IF;
END;
/

После WRAP 9.2.0.4 от процедуры остаются заголовок, словарь лексем (16 штук) и плоские таблицы дерева на 62 узла. Исходного текста там нет:

Фрагмент wrapped-файла процедуры bonus
CREATE OR REPLACE PROCEDURE bonus wrapped
…
9200000
…
1BONUS:  1P_EMPNO:  1NUMBER:  1V_SAL:  1SAL:  1EMP:  1EMPNO:  1=:  1<:
11000:   1I:        11:       13:       1LOOP: 1*:  12:

Дерево процедуры

Дерево хранится плоскими таблицами. Это таблицы типов узлов, ссылок на блоки атрибутов, самих атрибутов, списков и пара таблиц строк и колонок. Номер узла - порядок его создания при разборе, то есть снизу вверх. Поэтому листья идут первыми, а корень D_COMP_U получает номер 61 из 62. Ниже тот же массив, развёрнутый в дерево (в скобках номер узла; служебные атрибуты S_*, C_* в 9.2 нулевые, их не показываю):

Дерево DIANA процедуры bonus (31 строка)
D_COMP_U (61)
└ A_UNIT_B → D_S_BODY (58)                         тело процедуры BONUS
  ├ A_HEADER → D_P_ (7) → DS_PARAM → D_IN (5)      P_EMPNO IN NUMBER
  └ A_BLOCK_ → D_BLOCK (57)
    ├ AS_ITEM → DS_ITEM [D_VAR (12)]               V_SAL NUMBER
    ├ AS_ALTER → DS_ALTER []                       раздела EXCEPTION нет
    └ AS_STM → DS_STM (54) = [29, 53]
      ├ Q_SQL_ST (29)                                ── SELECT … INTO ──
      │ └ A_STM → Q_SELECT (26)
      │   ├ A_EXP → Q_EXP (27)
      │   │ ├ AS_EXP → DS_EXP [DI_U_NAM SAL]
      │   │ └ A_EXP → Q_TBL_EX (19)
      │   │   ├ AS_FROM  → DS_NAME [DI_U_NAM EMP]
      │   │   └ A_WHERE  → D_F_CALL (24)
      │   │     ├ A_NAME → D_USED_O "="
      │   │     └ AS_P_ASS → DS_PARAM [EMPNO, P_EMPNO]
      │   └ AS_INTO_ → DS_NAME [DI_U_NAM V_SAL]    ← INTO лежит рядом с запросом
      └ D_IF (53)                                    ── IF ──
        └ AS_LIST → [D_COND_C (52)]                  одна ветка условия
          ├ A_EXP_VO → D_F_CALL (34)                 V_SAL < 1000
          │ ├ A_NAME → D_USED_O "<"
          │ └ AS_P_ASS → DS_PARAM [DI_U_NAM V_SAL, D_NUMERI "1000"]
          └ AS_STM → DS_STM [D_LOOP (50)]            ── цикл ──
            ├ A_ITERAT → D_FOR (40)
            │ ├ A_ID    → DI_ITERA I
            │ └ A_D_R_  → D_RANGE (39)  A_EXP1 = D_NUMERI "1", A_EXP2 = D_NUMERI "3"
            └ AS_STM → DS_STM [D_ASSIGN (47)]
              ├ A_NAME → DI_U_NAM V_SAL
              └ A_EXP  → D_F_CALL (46)               V_SAL * 2
                ├ A_NAME → D_USED_O "*"
                └ AS_P_ASS → DS_PARAM [DI_U_NAM V_SAL, D_NUMERI "2"]

Что видно по дереву

  • Нет узлов для служебных слов. Ни END IF, ни END LOOP, ни ; в дереве нет. Рендерер сам пишет END IF; после списка веток и LOOP … END LOOP; вокруг тела.

  • IF - это список веток. Каждая ветка - D_COND_C с условием и телом. Первая ветка пишется как IF … THEN, следующие как ELSIF, а ветка без условия как ELSE.

  • Цикл - это заголовок плюс тело. Заголовок лежит в A_ITERAT. Это D_FOR, D_FOR_REVERSE, WHILE или FORALL. Если заголовка нет, получается простой LOOP. Диапазон выводится как нижняя..верхняя.

  • INTO хранится отдельно от запроса. Это атрибут AS_INTO_ узла Q_SELECT, а не часть Q_EXP. Распаковщик сначала собирает текст запроса (список выборки, FROM, WHERE) и потом вставляет строку INTO после второй строки.

  • Операторов в дереве нет, есть вызовы. <, * и = - это D_F_CALL с именем D_USED_O и списком операндов. Распаковщик собирает из них инфиксную запись. Цепочки вида a + b + c он разворачивает циклом по первому операнду.

  • Числа хранятся лексемами. 1000 лежит в словаре как строка "1000", значение S_VALUE не вычисляется.

  • Каждый узел проверяется. Рендерер сверяет типы ссылок и обратную ссылку A_UP на родителя. Если структура не сходится, он сообщает об ошибке структуры с номером узла, а не выдаёт мусор.

Восстановленный текст

Точный вывод распаковщика:

Восстановленный текст процедуры bonus (17 строк)
CREATE OR REPLACE PROCEDURE bonus( P_EMPNO IN NUMBER )
 IS
   V_SAL NUMBER;
 BEGIN
   SELECT
      SAL
      INTO V_SAL
      FROM EMP
      WHERE EMPNO = P_EMPNO;
   IF V_SAL < 1000 THEN
      FOR I IN 1..3
       LOOP
         V_SAL := V_SAL * 2;
      END LOOP;
   END IF;
END;
/

Текст совпадает с исходным по смыслу, но не по форме. В wrapped нет ни регистра, ни пробелов, ни комментариев, поэтому:

  • Имена стали заглавными. Заголовок bonus остался как был, потому что он хранится текстом до слова wrapped.

  • AS превратилось в IS. В 9.x дерево для них одинаковое, и распаковщик выбирает IS.

  • 1 .. 3 превратилось в 1..3, а LOOP ушёл на отдельную строку. Это канонический формат, а не восстановленное оформление автора.

Проверка обратным wrap

Результат я проверил обратным wrap той же версией:

  • wrap(восстановленный текст) даёт дерево, идентичное исходному по типам узлов, атрибутам, спискам и словарю. Различаются только таблицы строк и колонок, потому что раскладка текста другая.

  • unwrap(wrap(восстановленный текст)) совпадает с восстановленным текстом побайтно.

  • Если исходный текст уже в каноническом формате, как у образцов из дистрибутивов Oracle и синтетических тестов, wrap возвращает исходный wrapped-файл побайтно. Это основной критерий проверки.

Версии DIANA и раскладки атрибутов

Одна и та же конструкция в разных версиях даёт узлы с разным числом атрибутов, а файл этого числа не хранит. Распаковщик берёт его из таблицы раскладок: она извлечена из IDL-дескриптора WRAP 9.2.0.4, где записаны все пять версий DIANA. Новая версия только дописывает атрибуты в конец списка, поэтому раскладка старой версии - префикс новой.

Версии DIANA: релизы, число типов и слотов, что появилось

Версия DIANA

Релизы WRAP

Группа

Типов с атрибутами

Слотов в раскладке

Что появилось в дереве

200f000

2.2–2.3.4, слова …25ff

2.x

183

664

базовая раскладка PL/SQL 2

8003000

8.0.5

8.0

202

759

объектные типы, внешние процедуры и библиотеки, REF, наборы символов (A_CS), флаг коллекции A_TFLAG, RETURNING в DML

8105000

8.1.5 для Windows NT

8.1.5

216

819

FORALL и BULK COLLECT, EXECUTE IMMEDIATE и OPEN … FOR, AUTHID и схема в корне D_COMP_U

8106000

8.1.6, 8.1.7

8.1.6

218

826

SAMPLE, FETCH … LIMIT, RETURNING у EXECUTE IMMEDIATE

9000000

9.0.1; 9.2.0.4 пишет 9200000

9.0, 9.2

230

893

выражения CASE, COALESCE, NULLIF, PIPE ROW, атрибуты наследования типов, длина в символах, D_SQL_STMT, AGENT у библиотек

Последняя колонка - новые узлы и атрибуты из таблицы раскладок (например, D_FORALL и Q_EXEC_IMMEDIATE в 8.1.5, D_CASE_EXP и D_PIPE в 9.x). Юнит незнакомой версии читается по ближайшей младшей раскладке: 9200000 - как 9000000.

Откуда взялась таблица

IDL-описание - это дескриптор, на который в Linux-сборках WRAP 8.1.6–9.2 указывает символ plsdiana. В PLS234.DLL и PLS805.DLL символов нет, и дескриптор находится по ссылке на массив имён узлов. Структура дескриптора во всех релизах одинакова: номера атрибутов всех узлов, начало списка атрибутов каждого типа, их число, базовые типы атрибутов, имена и таблицы чисел по версиям.

Раскладки сверены с другими релизами и проверены на 1431 юните и 3,2 млн узлов: ни у одного узла блок в файле не короче раскладки. Пороги вроде «с Oracle 8i», подобранные по сэмплам, с ней не сходятся: UPDATE … RETURNING есть с 8.0.3, FETCH … LIMIT - с 8.1.6, AUTHID - с 8.1.5, а AGENT у LIBRARY - только с 9.0.

Релиз и форма SQL по дереву

Релиз нужен, чтобы выбрать WRAP для проверки круговым прогоном, а строка версии его не всегда называет. Поэтому распаковщик сообщает три значения: строку как в юните, релиз с точностью, которую позволяет юнит, и группу совместимости, например 2.2.2.3.1 [952825ff, compatible 2.x].

  • PL/SQL 2.2 или 2.3. Строку 200f000 пишут оба семейства, но деревья тел у них разные: 2.3 оставляет узлы D_NULL_S без родителя после каждого D_P_BODY и D_S_BODY, 2.2 таких узлов не создаёт. Признак проверен на 490 юнитах PL/SQL 2 с дисков Oracle 7.3; спецификации пакетов у 2.2 и 2.3 совпадают и получают релиз 2.x.

  • Форма статического SQL. Её видно по типам узлов: D_SQL_STMT значит текст, Q_SQL_ST с оператором кроме OPEN, FETCH, CLOSE - разобранный SQL, иначе статического SQL нет. Для кругового прогона юнита с текстом нужен WRAP 9.2.0.4 с edebug=wrap_new_sql.

Обратное преобразование: как устроен распаковщик

Распаковщик проходит путь WRAP в обратную сторону. Парсер читает таблицы в неизменяемую модель юнита, рендерер обходит дерево от корня и пишет текст в канонической раскладке, а потерянные детали добирает по следам в дереве. Публичный API один для V1 и V2, модель DIANA наружу не видна.

Правила рендерера

  • Один источник данных. Всё читается из модели юнита, таблицы не копируются. Атрибут адресуется номером в узле, а атрибут за пределами раскладки версии читается как 0, поэтому чужие атрибуты не попадают в текст.

  • Проверки в одном месте. Каждая ссылка сверяется со схемой узла: атрибут должен указывать на узел, список или лексему нужного вида. Испорченное дерево даёт ошибку структуры с номером и типом узла, а циклы в цепочках узлов обнаруживаются.

  • Разделение по областям. Обработчики объявлений, операторов, выражений и SQL - по несколько сотен строк каждый; обработчик выбирается по коду типа узла. Выражения возвращают текст, составные операторы сами пишут строки.

  • Раскладка как у эталона. SQL раскладывается как в выводе исходной программы REWRAP: переносы, отступы, выравнивание вложенных запросов. Для этого обработчики SQL передают вложенным узлам колонку выравнивания и режим.

Потерянное - по следам

Что восстановить

Откуда берётся

Заголовок: схема, OR REPLACE, регистр, кавычки

строка заголовка wrapped-юнита, а не дерево

IS или AS

след забегающего токена: порядок узлов и блоков атрибутов

Порядок предложений внешней процедуры, WORK, SAVEPOINT, BOTH

номера узлов-сирот ключевых слов

Перевод строки

перевод строки заголовка юнита во входном тексте: CRLF, LF или CR

Релиз PL/SQL и форма SQL

строка версии и признаки дерева

Проверка идёт на трёх уровнях. Тесты сравнивают вывод с эталонами байт в байт; круговой прогон требует, чтобы WRAP той же версии построил из восстановленного текста то же дерево; на wrapped-файлах из дистрибутивов Oracle вывод сверяется с REWRAP. Круговой прогон строже смысловой эквивалентности: из-за лексикона и нумерации узлов важен даже порядок слов.

Проблемы и подводные камни

Главная проблема V1 - дерево хранит смысл программы, но не её запись. Часть записи потеряна насовсем, часть остаётся лишь в косвенных следах, а сам формат содержит ловушки, на которых наивный распаковщик выдаёт неверный текст.

Теряется безвозвратно

  • Комментарии внутри юнита. Уцелевают только комментарии заголовка, хинты и SQL, сохранённый текстом.

  • Раскладка. Позиции узлов в файле есть, но пробелы и переносы между токенами по ним однозначно не восстанавливаются. Распаковщик пишет каноническую раскладку REWRAP.

  • Регистр имён без кавычек и ключевых слов: в лексиконе всё в верхнем регистре.

  • IS или AS в PL/SQL 9.x. WRAP 9.x строит для них одно дерево, поэтому пишется IS, как у REWRAP.

Неоднозначности, которые снимаются по следам

Проблема

Масштаб

Как снимается

IS или AS в PL/SQL 2.x–8.1

в wrapped-файлах Oracle 2374 заголовка 871 юнита восстанавливаются как AS

след забегающего токена

Порядок предложений внешней процедуры

3351 тело в файлах Oracle 8.0–9.2, 27 разных порядков; фиксированный порядок совпадает с исходником у 1678

номера узлов ключевых слов

Необязательные слова WORK, SAVEPOINT, BOTH, порядок START WITH и CONNECT BY

смысл не меняют, но добавляют лексему и узел

те же узлы и номера узлов

VARYING ARRAY или VARRAY

один узел D_ARRAY

сирота ARRAY перед литералом 1

Канонизированные формы: TRIM, CHR … USING, CASE без ELSE

парсер переписал конструкцию

служебные имена SYS$STANDARD_… и флаг 64

Ловушки формата

Восемь ловушек формата V1
  • Неявное число атрибутов. Без раскладки версии чтение уходит в атрибуты соседнего узла. Длину блока нельзя вычислить по ссылке на следующий узел: блоки не идут по порядку, а между ними бывают ничьи слоты.

  • Версия - не релиз. WRAP пишет последнюю известную ему версию DIANA, а WRAP для Windows PL/SQL 2.2 и 2.3.2 - слово …25ff. Семизначное слово вроде 92025ff легко принять за версию 9.2, если проверять его не первым.

  • 16-битные ссылки до 8.1.6. Старшие разряды ссылки на блок атрибутов лежат в коде типа узла; без декодирования большой юнит читается неверно.

  • Два формата лексикона и сырой CR. Возврат каретки из литерала записан без экранирования, поэтому строку лексикона завершает только LF или CRLF.

  • Хинты. До PL/SQL 9.0 WRAP теряет символ сразу после /*+.

  • Переводы строк. WRAP для Windows пишет CRLF и хранит перевод строки в литерале как LF. WRAP для Linux пишет LF и отвергает CR вне литералов ошибкой PSU(103,…), поэтому текст с CRLF ему не скормить.

  • Заголовок тела типа. WRAP копирует его до IS или AS вместе с пробелами. Если распаковщик добавит свой пробел, каждый круговой прогон добавит ещё один.

  • CREATE TYPE … AUTHID. WRAP 8.1.5–9.2.0.4 оставляет такой оператор незавёрнутым, и место AUTHID в его дереве неизвестно.

Ограничения самого WRAP

  • Нет семантической проверки: ошибки имён и типов видны только при загрузке .plb.

  • Совместимость только вверх: файл WRAP 8.1.6 не загрузится в 8.1.5.

  • Файл заметно больше исходника: в примере семь строк превратились в 146.

  • Часть синтаксиса SQL в 9.2 по умолчанию не поддержана; edebug=wrap_new_sql включает его ценой открытого SQL.

  • Заворачиваются только подпрограммы, пакеты и объектные типы (в сэмплах есть ещё LIBRARY); остальные команды идут в .plb как есть.

  • Есть и предел компилятора: 2^26 узлов DIANA на модуль, при превышении - ошибка вроде PLS-00123.

Что мешает при разборе реальных wrapped-файлов

Wrapped-файлы из живых систем редко выглядят так, как их описывает документация: исходники переносили между Windows и Unix, а файлы собирали разными релизами WRAP. Ниже - проблемы, на которые натыкаешься именно на таких файлах.

CR на границе блока в 512 байт

Попадаются wrapped-пакеты, исходник которых был в Windows-формате с CRLF, а заворачивался в Unix или Linux, где строка кончается LF. CR перед LF убирается не везде: исходник читается блоками по 512 байт от начала файла, и пара CR LF распознаётся только внутри блока.

  • Где остаётся CR. Если CR стоит последним байтом блока - на смещении 511, 1023 и так далее, - а LF открывает следующий блок, CR переживает обработку. В юните с LF появляются единичные CRLF, в том числе внутри строковых литералов.

  • От чего зависит. Набор уцелевших CR определяется тем, какие строки исходного файла случайно закончились на границе блока. В восстановленном скрипте строки стоят на других смещениях, поэтому повторный WRAP убирает другие CR и даёт другие байты.

  • Что делать при разборе. Парсер не должен считать одиночный CR концом строки и не должен «чистить» такие CR в литералах: это часть значения, которое видела база. Восстановленный текст при этом смешивает LF и CRLF, и это точное воспроизведение исходника, а не ошибка.

В формате V2 это заметнее всего: так работают WRAP 10.2.0.1, 10.2.0.5, 11.1.0.6 и 11.2.0.1, а текст там сжат, и перекодировка файла до него не дотягивается. Даже в дистрибутиве Oracle 12.2.0.1 есть такие юниты: ords_migrate и upg_oauth с 3 и 1 оставшимся CRLF.

8.1.6 и 8.1.7: один формат, разный вывод

WRAP 8.1.6 и 8.1.7 генерируют код одного формата: строка версии 8106000 и одна раскладка атрибутов. Но вывод для одного и того же исходника у них отличается.

  • По строке версии релиз не определить: распаковщик сообщает диапазон 8.1.6-8.1.7.

  • Для побайтового кругового прогона нужен WRAP именно того релиза, что собирал файл: совпадение версии DIANA этого не гарантирует.

  • Сравнивать файлы 8.1.6 и 8.1.7 по байтам бессмысленно - сравнивается восстановленный текст.

2.2 и 2.3.2: адрес вместо версии

WRAP для Windows PL/SQL 2.2 и 2.3.2 пишет на месте версии не 200f000, а адрес переменной с версией. Точнее, первые четыре байта переходника импорта jmp [slot]: FF 25 и младшие байты адреса, отсюда слова вроде 952825ff, a54025ff, 95a025ff. На самом деле это 200f000: дерево и раскладка атрибутов те же, что у 2.3.4.

  • Слово на 25ff надо проверять до разбора версии, иначе семизначный вариант вроде 92025ff легко принять за 9.2 и прочитать дерево чужой раскладкой.

  • Адрес зависит от сборки, поэтому слово заодно указывает на релиз: 952825ff и a54025ff - 2.2.2.3.1, 95a025ff - 2.3.2.3.0, остальные известные - семейство 2.3.

  • Незнакомое слово на 25ff читается как PL/SQL 2.x, а 2.2 или 2.3 определяется по дереву.

Несовместимые изменения внутри одной версии DIANA

При переходе от версии к версии PL/SQL иногда появлялись «несовместимые» изменения, даже когда строка версии в файле оставалась прежней. Самый наглядный пример - переход от PL/SQL 2.2 к 2.3: оба враппера относятся к версии 200f000, но в таблице атрибутов между ними произведено переименование.

  • Что изменилось. Атрибут на той же позиции в 2.2 и 2.3.2 называется S_NORMAL, а в 2.3.3 и 2.3.4 - S_NORMARGLIST. Кроме того, 2.3 оставляет в телах узлы D_NULL_S без родителя, а 2.2 - нет, то есть деревья одной и той же версии DIANA различаются.

  • Как это видно в дистрибутивах. Из-за переименования Oracle сохранил в ранних дистрибутивах два разных враппера - для Oracle 7.2 и для Oracle 7.3.

  • Совместимость спрятана в базе. База 7.3 понимает атрибутику от 7.2: где-то внутри транслятора совместимость сохранена. Но на уровне враппера и файла этого не видно: строка версии одинакова, и кто именно завернул юнит, приходится определять по дереву.

  • Что это значит для распаковщика. Раскладку атрибутов нельзя считать однозначно заданной строкой версии, а для кругового прогона нужен враппер именно того семейства - 2.2 или 2.3, - которым файл был собран.

Насколько wrap V1 защищает код

Слабо: V1 - сериализованное дерево с открытым лексиконом, а не шифр. Документация Oracle 9.2 сама предупреждает: литералы и имена переменных, таблиц и столбцов остаются открытым текстом, а пароли и имена таблиц так не спрячешь.

  • Лексикон читается без инструментов. По Финнигану, видимая таблица символов выдаёт назначение процедуры и облегчает поиск SQL-инъекций. Он же показал правку wrapped-кода без распаковки.

  • Структура известна. Имена узлов и атрибутов лежат в pidian.sql дистрибутива, раскладки - в дескрипторе самого WRAP. Позиции узлов связывают дерево со строками исходника.

  • Восстановление точное. Проект возвращает текст, из которого WRAP той же версии строит то же дерево, а с эталонами сэмплов совпадает байт в байт.

  • В 10g схему сменили. Формат V2 хранит сжатый текст без DIANA; его распаковка реализована в репозитории oracle-plsql-unwrap.

Итог: wrap V1 защищает от случайного взгляда, но не от целенаправленного чтения.

Что есть из готовых инструментов

Для формата V1 готовых инструментов немного, и у каждого есть ограничения:

  • REWRAP.EXE - DOS-утилита на Clipper 1993 года. Совместима только с WRAP 2.2 и частично с 2.3 и работает очень медленно - единицы строк в секунду. Её вывод задаёт каноническую раскладку, которую повторяет распаковщик из этой статьи.

  • rstenet/9i-unwrapper - proof of concept на PL/SQL для 9i и, вероятно, более ранних версий. По README реализовано около 40 из примерно 300 узлов DIANA: такие конструкции, как LOOP, CURSOR, IF и EXCEPTION, не поддерживаются и помечаются в выводе, часть результата приходится править вручную, чтобы он скомпилировался, а исходная раскладка не восстанавливается.

  • Jimmy45M/PL-SQL-Unwrapper - пакет PL/SQL, который работает только внутри базы Oracle 10.2 и новее и читает код из DBA_SOURCE, то есть юнит сначала надо загрузить в базу. Понимает V1 и V2, но код Oracle 7 не поддерживает, а вывод V1 автор советует перепроверять. Для V1 в 21c нужен параметр PERMIT_92_WRAP_FORMAT, в 23ai формат V1 уже не загружается.

  • Распаковщик Пита Финнигана на PL/SQL. В блоге он показывает восстановление кода WRAP 7.2–9.2 с проверкой обратным wrap, но в открытом доступе инструмента я не нашёл.

  • Демонстрация на пакетах DIANA и PIDL (Финниган, unwrap_r.sql) показывает только сигнатуры пакетов, тела процедур через этот API не достать.

  • Инструменты для 10g и новее - RemmyLee/Oracle-SQL-Unwrapper (Python/Flask), unwrap.py, расширение Unwrapper для SQL Developer, онлайн-сервисы - рассчитаны на формат V2. Wrapped-код до 10g они не принимают.

Что удаётся сейчас

На данный момент мне удаётся развернуть любой код Oracle от версии 7.2 до 26ai с полным совпадением по исполнению после обратного wrapping-а. Расхождения допустимы только в двух случаях:

  • в комментариях REM, если они были добавлены в итоговый документ;

  • в лишних CR, если файл заворачивался при смешении CRLF и LF в исходнике и результате.

На логику и возможность компиляции эти расхождения не влияют.

Оговорка: речь о восстановлении собственного кода или кода, на который у вас есть права. Лицензионные соглашения Oracle, как правило, запрещают декомпиляцию и обратную разработку поставляемых программ, так что разворачивать чужие пакеты без таких прав не стоит.

Если столкнулись с похожей задачей - восстановить потерянный код или перенести старую систему, - пишите на ashindarev@gmail.com или в LinkedIn: ashindarev, обсудим.

Источники

Комментарии (0)