Уважаемые любители караоке KaraFun!
Хочу надеяться, что среди вас найдутся программисты, способные написать ряд утилит для работы с файлами формата KFN. Долгие годы поиска информации в Интернете касательно закрытого формата KFN показали практически полное отсутствие какой-либо информации по формату KFN, поэтому пришлось прибегнуть к реверс-инжинирингу (обратному инжинирингу) для того, чтобы разобраться в формате KFN.
Надеюсь, что эта информация будет полезна для людей, которые хотели бы поддержать KaraFun файлы в своих проектах.
Данную информацию вы вряд ли найдёте ещё где-либо.
Для обратного инжиниринга использовался Linux. Весь процесс обратного инжиниринга происходил с помощью инструментов командной строки. Были написаны несколько программ на Java для автоматизации процессов. Для работы под Windows необходимо установить Cygwin и ещё несколько инструментов для работы с шестнадцатиричным кодом.
Перед тем, как исследовать файл KaraFun необходимо знать точную информацию о нём, поэтому лучше сделать его самому. Тогда мы будем знать, что этот файл караоке содержит, по крайней мере, музыку и тексты песен. Следовательно тип этого файла-контейнера - архив.
В каждом файле-контейнере KFN есть, по крайней мере, три части информации:
1. Контейнер заголовка файла, который идентифицирует такие вещи, как тип контейнера, версия, создание / дата обновления и так далее. Как правило, он находится в самом начале файла.
2. Каталог контейнера, в котором перечислены файлы, хранящиеся в контейнере. Как минимум он содержит два поля, смещение в файле контейнера и длину. Он также может содержать дополнительные поля, такие как контрольная сумма, флаги (запакован / зашифрован и каким образом), дату создания / изменения файла, метки времени, имя и так далее. Каталог может быть централизованным (т.е. информации о всех файлах хранятся вместе) или децентрализованная (каждый файл начинается своим собственным каталогом)
3. Сохраненные файлы.
Первое, что нужно сделать, это HEX-дамп файла:
> hexdump -C 1.kfn | less
00000000 4b 46 4e 42 44 49 46 4d 01 02 00 00 00 44 49 46 |KFNBDIFM.....DIF|
00000010 57 01 02 00 00 00 47 4e 52 45 01 74 00 00 00 53 |W.....GNRE.t...S|
00000020 46 54 56 01 53 00 0a 01 4d 55 53 4c 01 1d 01 00 |FTV.S...MUSL....|
00000030 00 41 4e 4d 45 01 0c 00 00 00 54 59 50 45 01 00 |.ANME.....TYPE..|
00000040 00 00 00 46 4c 49 44 02 10 00 00 00 00 00 00 00 |...FLID.........|
Здесь мы можем видеть, что контейнер KFN начинается с заголовка с несколькими секциями. Давайте посмотрим немного дальше:
00000200 00 00 00 00 00 00 08 00 00 00 6e 62 72 34 2e 6a |..........nbr4.j|
00000210 70 67 03 00 00 00 26 a1 01 00 aa 9a 8f 00 26 a1 |pg....&.......&.|
00000220 01 00 00 00 00 00 08 00 00 00 6e 62 72 35 2e 6a |..........nbr5.j|
00000230 70 67 03 00 00 00 88 92 00 00 d0 3b 91 00 88 92 |pg.........;....|
00000240 00 00 00 00 00 00 08 00 00 00 53 6f 6e 67 2e 69 |..........Song.i|
00000250 6e 69 01 00 00 00 f3 12 00 00 58 ce 91 00 f3 12 |ni........X.....|
00000260 00 00 00 00 00 00 ff fa 92 60 9e d6 00 00 02 45 |.........`.....E|
Это выглядит как централизованный каталог, он содержит имена файлов. В нём имеется файл Song.ini - последний файл в каталоге. Так как это INI файл, он может быть текстовый файл, так что давайте посмотрим на конец файла KFN:
0091e340 e1 bb 8d 69 20 c4 91 c3 a0 6e 67 2e 2e 0d 0a 49 |...i ....ng....I|
0091e350 6e 53 79 6e 63 3d 31 0d 0a 0d 0a 5b 4d 50 33 4d |nSync=1....[MP3M|
0091e360 75 73 69 63 5d 0d 0a 4e 75 6d 54 72 61 63 6b 73 |usic]..NumTracks|
0091e370 3d 31 0d 0a 54 72 61 63 6b 30 3d 2c 30 2c 2d 31 |=1..Track0=,0,-1|
0091e380 2c 30 39 2d 20 43 68 75 61 20 62 69 65 74 20 72 |,09- Chua biet r|
0091e390 6f 2c 30 39 2d 43 68 75 61 20 62 69 65 74 20 72 |o,09-Chua biet r|
0091e3a0 6f 2d 68 61 74 2d 6d 69 78 2e 6d 70 33 0d 0a 0d |o-hat-mix.mp3...|
0091e3b0 00 |.|
Здесь мы видим текст песни. Поэтому можно сделать вывод о том, что файлы хранятся как есть, они не упакованы и не зашифрованы, и нет сноски в конце файла-контейнера. Тут всё пока легко и просто.
Теперь давайте вернемся к заголовку и посмотрим на него более внимательно:
00000000 4b 46 4e 42 44 49 46 4d 01 02 00 00 00 44 49 46 |KFNBDIFM.....DIF|
00000010 57 01 02 00 00 00 47 4e 52 45 01 74 00 00 00 53 |W.....GNRE.t...S|
00000020 46 54 56 01 53 00 0a 01 4d 55 53 4c 01 1d 01 00 |FTV.S...MUSL....|
00000030 00 41 4e 4d 45 01 0c 00 00 00 54 59 50 45 01 00 |.ANME.....TYPE..|
00000040 00 00 00 46 4c 49 44 02 10 00 00 00 00 00 00 00 |...FLID.........|
00000050 00 00 00 00 00 00 00 00 00 00 00 00 54 49 54 4c |............TITL|
00000060 02 0c 00 00 00 43 68 75 61 20 62 69 65 74 20 72 |.....Chua biet r|
00000070 6f 41 52 54 53 02 0e 00 00 00 41 75 67 75 73 74 |oARTS.....August|
00000080 69 6e 6f 20 48 6f 61 69 41 4c 42 4d 02 0c 00 00 |ino HoaiALBM....|
00000090 00 43 68 75 61 20 62 69 65 74 20 72 6f 43 4f 4d |.Chua biet roCOM|
000000a0 50 02 0e 00 00 00 41 75 67 75 73 74 69 6e 6f 20 |P.....Augustino |
000000b0 48 6f 61 69 53 4f 52 43 02 1c 00 00 00 31 2c 49 |HoaiSORC.....1,I|
000000c0 2c 30 39 2d 20 43 68 75 61 20 62 69 65 74 20 72 |,09- Chua biet r|
000000d0 6f 2d 6d 69 78 2e 6d 70 33 54 52 41 4b 02 01 00 |o-mix.mp3TRAK...|
000000e0 00 00 31 52 47 48 54 01 00 00 00 00 50 52 4f 56 |..1RGHT.....PROV|
000000f0 01 00 00 00 00 49 44 55 53 02 10 00 00 00 20 20 |.....IDUS..... |
00000100 20 20 20 20 20 20 20 20 20 20 20 20 20 20 45 4e | EN|
00000110 44 48 01 ff ff ff ff 09 00 00 00 1b 00 00 00 30 |DH.............0|
Поскольку заголовок содержит строки, он не может использовать поля фиксированной длины (например, чтобы каждое поле было точно длиной 10 байт), как это будет расходовать дисковое пространство и память. Должен быть какой-то другой способ, чтобы выяснить длину поля заголовка. Посмотрим на строки с 50 по 70:
00000050 00 00 00 00 00 00 00 00 00 00 00 00 54 49 54 4c |............TITL|
00000060 02 0c 00 00 00 43 68 75 61 20 62 69 65 74 20 72 |.....Chua biet r|
00000070 6f 41 52 54 53 02 0e 00 00 00 41 75 67 75 73 74 |oARTS.....August|
Название песни указывается как «Chua Biet rо». Перед названием песни стоит префикс TITL. Можно разумно предполагать, что TITL означает «Название песни». Однако название песни может иметь большую длину. Как же определить маркер конца строки с названием? Есть несколько способов, чтобы хранить строки в памяти. Одним из них является префикс языка C++, когда строка завершается значением 0x00. Еще одним является префикс языка Паскаль, когда строка начинается с префикса длины строки, хранящейся в первых 2 или 4 байтах.
Префикса 0х00 не видно в конце заголовка, и два шестнадцатеричных символа перед названием 0x00 0x00 не могут кодировать длину строки. Посмотрим дальше на четыре байта. Там следуют 0x0c 0x00 0x00 0x00. Используется прямой порядок байтов кодировки - это означает 0x0000000C или 12. Что в конечном счёте соответствует длине названия песни - «Chua Biet rо».
Остаётся единственный вопрос - что означает префикс 0x02 между TITL и маркером длины. Для того, чтобы это выяснить, посмотрим на других полях, список префиксов, промежуточное значение и длину поля, которые можно легко увидеть, так как поле заканчивается, когда начинается новый заголовок:
DIFW - value 0x01 - followed by 4 bytes, value 0x00000002
GNRE - value 0x01 - followed by 4 bytes, value 0x00000074
SFTV - value 0x01 - followed by 4 bytes, value 0x010A0053
MUSL - value 0x01 - followed by 4 bytes, value 0x0000011D
ANME - value 0x01 - followed by 4 bytes, value 0x0000000C
TYPE - value 0x01 - followed by 4 bytes, value 0x000000000
FLID - value 0x02 - followed by 4 bytes length (0x10) followed by 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
TITL - value 0x02 - followed by 4 bytes length (0x0C) followed by "Chua biet ro"
Теперь мы можем описать структуру заголовка файла KFN:
Первые четыре байта файла KFN начинается с символов «KFNB», а затем идут заголовки.
Каждый заголовок имеет следующую структуру:
- четыре символа имя поля (такую же, как используется RIFF контейнер), а затем идут флаги.
- Если значение флага 0x01, то следующие 4 байта после флага содержит значение для этого поля.
- Если значение флага 0x02, то следующий 4 байта содержит длину строки, которая следует до байта длины.
- Заголовок заканчивается полем EndH.
Давайте напишем небольшую программу Java, чтобы прочитать заголовок в удобочитаемом виде:
import java.io.File;
import java.io.IOException;
import java.io.FileNotFoundException;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
class KFNDumper
{
public boolean parse( String fontFilename )
{
try
{
m_file = new RandomAccessFile( fontFilename, "r" );
// Read the file signature
String signature = new String( readBytes(4) );
if ( !signature.equals("KFNB") )
return false;
// Read the header
while ( true )
{
signature = new String( readBytes(4) );
int type = readByte();
int len_or_value = readDword();
switch ( type )
{
case 1:
System.out.println( signature + ", type 1, value " + String.format("%x", len_or_value));
break;
case 2:
byte[] buf = readBytes( len_or_value );
System.out.println( signature + ", type 2, length " + len_or_value + ", hex: " + dumpHex( buf ) + ", string: \"" + Charset.forName( "UTF-8" ).decode( ByteBuffer.wrap( buf ) ).toString() + "\"" );
break;
}
if ( signature.equals("ENDH") )
break;
}
System.out.println( "Header ends at offset " + m_file.getFilePointer() );
return true;
}
catch (IOException e)
{
// Most likely a corrupted font file
return false;
}
}
// KFN file; must be seekable
private RandomAccessFile m_file = null;
// Helper I/O functions
private int readByte() throws IOException
{
return m_file.read() & 0xFF;
}
private int readWord() throws IOException
{
int b1 = readByte();
int b2 = readByte();
return b2 << 8 | b1;
}
private int readDword() throws IOException
{
int b1 = readByte();
int b2 = readByte();
int b3 = readByte();
int b4 = readByte();
return b4 << 24 | b3 << 16 | b2 << 8 | b1;
}
private byte [] readBytes( int length ) throws IOException
{
byte [] array = new byte [ length ];
if ( m_file.read( array ) != length )
throw new IOException();
return array;
}
private String dumpHex( byte [] array )
{
String out = "";
for ( int i = 0; i < array.length; i++ )
{
if ( i > 0 )
out += " ";
out += String.format("%02X", array[i] & 0xFF);
}
return out;
}
private String readUtf8String( int length ) throws IOException
{
// Allocate the buffer and read into it
byte[] buf = readBytes( length );
// And decode the UTF-8 string
return Charset.forName( "UTF-8" ).decode( ByteBuffer.wrap( buf ) ).toString();
}
private String readUtf8String() throws IOException
{
// First four bytes define the length
return readUtf8String( readDword() );
}
public static void main( String [] args ) throws Exception
{
if ( args.length == 0 )
{
System.out.println( "Usage: app <KFN file>\n" );
return;
}
KFNDumper dumper = new KFNDumper();
dumper.parse( args[0] );
}
}
Сохраните его в файл KFNDumper.java, скомпилируйте и запустите:
> javac KFNDumper.java
> java KFNDumper 1.kfn
DIFM, type 1, value 2
DIFW, type 1, value 2
GNRE, type 1, value 74
SFTV, type 1, value 10a0053
MUSL, type 1, value 11d
ANME, type 1, value c
TYPE, type 1, value 0
FLID, type 2, length 16, hex: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00, string: ""
TITL, type 2, length 12, hex: 43 68 75 61 20 62 69 65 74 20 72 6F, string: "Chua biet ro"
ARTS, type 2, length 14, hex: 41 75 67 75 73 74 69 6E 6F 20 48 6F 61 69, string: "Augustino Hoai"
ALBM, type 2, length 12, hex: 43 68 75 61 20 62 69 65 74 20 72 6F, string: "Chua biet ro"
COMP, type 2, length 14, hex: 41 75 67 75 73 74 69 6E 6F 20 48 6F 61 69, string: "Augustino Hoai"
SORC, type 2, length 28, hex: 31 2C 49 2C 30 39 2D 20 43 68 75 61 20 62 69 65 74 20 72 6F 2D 6D 69 78 2E 6D 70 33, string: "1,I,09- Chua biet ro-mix.mp3"
TRAK, type 2, length 1, hex: 31, string: "1"
RGHT, type 1, value 0
PROV, type 1, value 0
IDUS, type 2, length 16, hex: 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20, string: " "
ENDH, type 1, value ffffffff
Header ends at offset 279
Все поля имеют очевидные значения, смысл которых можно посмотреть в редакторе KaraFun Studio.
- TITL - название песни,
- ARTS - Имя исполнителя,
- ТРАК - номер дорожки и т.п.,
Тем не менее, мы упускаем ключевую часть информации - каталог location. Нам нужно найти его.
Мы знаем, что в файле должен быть каталог, так как плеер KaraFun знает, где именно в файле KFN хранятся файлы и их размеры. Как минимум должен быть указатель смещения, указан размер и количество файлов.
На первый взгляд заголовок DIFW может содержать количество файлов, а значение MUSL содержит каталог смещения (его значение 0x11D которое после 0x117). Однако, если мы проверим другие файлы KaraFun на той же странице, мы увидим, что для некоторых файлов значение MUSL меньше длины заголовка. Поэтому он не может быть смещением, и, вероятно, является длиной музыки в секундах.
Также DIFW содержит количество файлов. Быстрый поиск для JPEG подписи «JFIF» показывает, что по крайней мере имеется три JPG файла, то есть однозначно более двух файлов в этом архиве.
Так, где эта директория?
Так как длина заголовка изменяется (поскольку он использует строки с переменной длиной), это может быть в одном из двух мест. Либо она находится в конце файла, либо она должна следовать непосредственно после заголовка.
Давайте внимательно посмотрим на байт после заголовка:
00000110 XX XX XX XX XX XX XX 09 00 00 00 1b 00 00 00 30 |...............0|
00000120 39 2d 43 68 75 61 20 62 69 65 74 20 72 6f 2d 68 |9-Chua biet ro-h|
00000130 61 74 2d 6d 69 78 2e 6d 70 33 02 00 00 00 bc ac |at-mix.mp3......|
00000140 45 00 00 00 00 00 bc ac 45 00 00 00 00 00 18 00 |E.......E.......|
00000150 00 00 30 39 2d 20 43 68 75 61 20 62 69 65 74 20 |..09- Chua biet |
00000160 72 6f 2d 6d 69 78 2e 6d 70 33 02 00 00 00 5e ae |ro-mix.mp3....^.|
00000170 45 00 bc ac 45 00 5e ae 45 00 00 00 00 00 10 00 |E...E.^.E.......|
00000180 00 00 4e 67 61 69 20 62 69 65 74 20 72 6f 2e 6a |..Ngai biet ro.j|
00000190 70 67 03 00 00 00 01 66 01 00 1a 5b 8b 00 01 66 |pg.....f...[...f|
000001a0 01 00 00 00 00 00 08 00 00 00 6e 62 72 31 2e 6a |..........nbr1.j|
000001b0 70 67 03 00 00 00 71 50 01 00 1b c1 8c 00 71 50 |pg....qP......qP|
000001c0 01 00 00 00 00 00 08 00 00 00 6e 62 72 32 2e 6a |..........nbr2.j|
000001d0 70 67 03 00 00 00 b2 bd 00 00 8c 11 8e 00 b2 bd |pg..............|
000001e0 00 00 00 00 00 00 08 00 00 00 6e 62 72 33 2e 6a |..........nbr3.j|
000001f0 70 67 03 00 00 00 6c cb 00 00 3e cf 8e 00 6c cb |pg....l...>...l.|
00000200 00 00 00 00 00 00 08 00 00 00 6e 62 72 34 2e 6a |..........nbr4.j|
00000210 70 67 03 00 00 00 26 a1 01 00 aa 9a 8f 00 26 a1 |pg....&.......&.|
00000220 01 00 00 00 00 00 08 00 00 00 6e 62 72 35 2e 6a |..........nbr5.j|
00000230 70 67 03 00 00 00 88 92 00 00 d0 3b 91 00 88 92 |pg.........;....|
00000240 00 00 00 00 00 00 08 00 00 00 53 6f 6e 67 2e 69 |..........Song.i|
00000250 6e 69 01 00 00 00 f3 12 00 00 58 ce 91 00 f3 12 |ni........X.....|
00000260 00 00 00 00 00 00 ff fa 92 60 9e d6 00 00 02 45 |.........`.....E|
MP3 file start
Это полный каталог, так как по смещению 0x266 MP3-файл начинается с FF FA подписи. Глядя в начале мы видим следующие смещения, начиная с 0x117:
- Смещение 0x00: значение 0x00000009 - не знаю, что это такое. Это может быть длинна или смещение, а может некоторая информация о файле?
- Смещение 0x04: 0x0000001B затем имя файла. Из опыта работы с хранением строки в заголовке, можно предположить, что это длина строки. Длина «09-Chua Biet ро-хет-mix.mp3» равна 27, что является - 1В.
- Смещение 0x23: 0x00000002 - тоже не имеет смысла, не может быть длиной. Оставим это сейчас.
- Смещение 0x27: 0x0045Ae5e - выглядит как длина файла, Давайте проверим это. Мы знаем , что MP3 начинается 0x266, поэтому она должна закончиться в 45B0C4. Давайте посмотрим там:
0045b0c0 20 71 91 8c ff fa 92 60 75 c7 73 00 01 2c 02 c7 | q.....`u.s..,..|
Да! Не только файл MP3 заканчивается там, но есть другой MP3-файл, который начинается прямо там. Это имеет смысл, потому что второе имя файла в каталоге также MP3-файла. Поэтому мы можем быть уверены, что это длина.
Смещение 0x2B: 0x00000000 - еще один набор флагов? Не знаю, пока оставим это.
Offset 0x2F: 0x0045Ae5e - другая длина. Это не очень хорошо, так как это означает, что файл может быть упакован. В этом случае одна из длин - распакованноая длина и еще одна - упакованная длина файла.
Смещение 0x33: 0x00000000 - пока неясно, что это. Может быть началом следующей информации о файле (например, 0x00000009 выше).
А потом следует с 0x00000018 и другим именем файла, то есть информация об окончании файла. Нам нужно выяснить, где информация о первом файле заканчивается и начинается о втором.
Для этого нужно посмотреть информацию Song.ini:
В 0x0252: 0x00000001
В 0x0256: 0x000012F3 (мы знаем , что это длина файла)
В 0x025A: 0x0091CE58 - выглядит как смещение. Поищем там Song.ini. Если мы посмотрим на то же поле для первого файла MP3, то значение равно 0. Но, очевидно, MP3 - файл не хранится по смещению 0, он начинается с 0x266. Таким образом, можно предположить, что смещение файла начинается с указанного места в каталоге и заканчивается в нашем случае 0x266.
Добавляем 0x266 + 0x0091CE58 и получаем 0x0091D0BE. Это очевидно из шестнадцатеричного дампа:
0091d0a0 a2 e3 0f 81 fc fd 9f f0 a2 ba bf 03 f8 1e e7 c2 |................|
0091d0b0 d7 d1 fe ec f9 19 1d be ed 15 2c 67 ff d9 5b 47 |..........,g..[G|
0091d0c0 65 6e 65 72 61 6c 5d 0d 0a 54 69 74 6c 65 3d 43 |eneral]..Title=C|
0x25E: 0x000012F3 - снова, это длина в упакованном или неупакованном виде
0x262: 0x00000000 - Какая-то информация. Это последнее поле.
Таким образом, мы видим, что имя каждого файла составляет ровно 20 байт. Вернёмся к первому файлу и подсчитаем 20 байт. В конечном итоге мы окажемся на начале второго имени файла.
Поэтому каталог имеет следующую структуру:
4 байта длины строки следуют строки
4 байта некоторая информация (тип файла)
4 байта длины файла, мы называем это length1
4 байта смещения (начинается с конца каталога)
4 байта другой файл Длина (lenght2)
4 байта другой информации (флаги файла)
Все записи соответствуют этой структуре, кроме первого, который начинается с 0x00000009 с последующей длиной. Это может быть общее количество файлов. В конце концов, заголовок каталога заканчивается без какого-либо знака конца заголовка, поэтому мы точно должны знать, сколько файлов имеется в контейнере. Если мы прочитаем имена файлов, то получим 9. Таким образом, первые 4 байта в каталоге указывают количество файлов в архиве.
Давайте получим информацию о каталоге с помощью следующей программы:
import java.io.File;
import java.io.IOException;
import java.io.FileNotFoundException;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
class KFNDumper
{
public boolean parse( String fontFilename )
{
try
{
m_file = new RandomAccessFile( fontFilename, "r" );
// Read the file signature
String signature = new String( readBytes(4) );
if ( !signature.equals("KFNB") )
return false;
// Parse the header fields
while ( true )
{
signature = new String( readBytes(4) );
int type = readByte();
int len_or_value = readDword();
switch ( type )
{
case 1:
break;
case 2:
byte[] buf = readBytes( len_or_value );
break;
}
if ( signature.equals("ENDH") )
break;
}
// Read the number of files in the directory
int numFiles = readDword();
// Parse the directory
for ( int i = 0; i < numFiles; i++ )
{
int filenameLen = readDword();
byte[] filename = readBytes( filenameLen );
int file_type = readDword();
int file_length1 = readDword();
int file_offset = readDword();
int file_length2 = readDword();
int file_flags = readDword();
System.out.println( "File " + Charset.forName( "UTF-8" ).decode( ByteBuffer.wrap( filename ) ).toString()
+ ", type: " + file_type + ", length1: " + file_length1 + ", length2: "
+ file_length2 + ", offset: " + file_offset + ", flags: " + file_flags );
}
System.out.println( "Directory ends at offset " + m_file.getFilePointer() );
return true;
}
catch (IOException e)
{
// Most likely a corrupted font file
return false;
}
}
// KFN file; must be seekable
private RandomAccessFile m_file = null;
// Helper I/O functions
private int readByte() throws IOException
{
return m_file.read() & 0xFF;
}
private int readWord() throws IOException
{
int b1 = readByte();
int b2 = readByte();
return b2 << 8 | b1;
}
private int readDword() throws IOException
{
int b1 = readByte();
int b2 = readByte();
int b3 = readByte();
int b4 = readByte();
return b4 << 24 | b3 << 16 | b2 << 8 | b1;
}
private byte [] readBytes( int length ) throws IOException
{
byte [] array = new byte [ length ];
if ( m_file.read( array ) != length )
throw new IOException();
return array;
}
private String dumpHex( byte [] array )
{
String out = "";
for ( int i = 0; i < array.length; i++ )
{
if ( i > 0 )
out += " ";
out += String.format("%02X", array[i] & 0xFF);
}
return out;
}
private String readUtf8String( int length ) throws IOException
{
// Allocate the buffer and read into it
byte[] buf = readBytes( length );
// And decode the UTF-8 string
return Charset.forName( "UTF-8" ).decode( ByteBuffer.wrap( buf ) ).toString();
}
private String readUtf8String() throws IOException
{
// First four bytes define the length
return readUtf8String( readDword() );
}
public static void main( String [] args ) throws Exception
{
if ( args.length == 0 )
{
System.out.println( "Usage: app <KFN file>\n" );
return;
}
KFNDumper dumper = new KFNDumper();
dumper.parse( args[0] );
}
}
После компиляции и запуска получаем следующий результат:
File 09-Chua biet ro-hat-mix.mp3, type: 2, length1: 4566204, length2: 4566204, offset: 0, flags: 0
File 09- Chua biet ro-mix.mp3, type: 2, length1: 4566622, length2: 4566622, offset: 4566204, flags: 0
File Ngai biet ro.jpg, type: 3, length1: 91649, length2: 91649, offset: 9132826, flags: 0
File nbr1.jpg, type: 3, length1: 86129, length2: 86129, offset: 9224475, flags: 0
File nbr2.jpg, type: 3, length1: 48562, length2: 48562, offset: 9310604, flags: 0
File nbr3.jpg, type: 3, length1: 52076, length2: 52076, offset: 9359166, flags: 0
File nbr4.jpg, type: 3, length1: 106790, length2: 106790, offset: 9411242, flags: 0
File nbr5.jpg, type: 3, length1: 37512, length2: 37512, offset: 9518032, flags: 0
File Song.ini, type: 1, length1: 4851, length2: 4851, offset: 9555544, flags: 0
Directory ends at offset 614
Теперь становится ясно, что в начале идёт информация об имени файла.
Двойное слово является типом файла:
- со значением значением 2 музыкальный файл,
- значение 3 означает файл изображения,
- значение 1 означает файл песни.
Глядя на другие файлы KFN мы также можем сказать, что:
- значение 4 представляет собой файл шрифта,
- значение 5 представляет собой видеофайл.
Кроме того, имя файла хранится в родной кодировке файловой системы, а не в UTF-8, что делает невозможным восстановить исходное имя файла, не зная его кодировку. Однако, так как у нас есть тип файла, нам не нужно имя файла для его проигрывания в плеере.
Следующая программа может извлечь файлы из контейнера:
import java.io.File;
import java.io.IOException;
import java.io.FileOutputStream;
import java.io.FileNotFoundException;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.List;
class KFNDumper
{
public static final int TYPE_SONGTEXT = 1;
public static final int TYPE_MUSIC = 2;
public static final int TYPE_IMAGE = 3;
public static final int TYPE_FONT = 4;
public static final int TYPE_VIDEO = 5;
// KFN file; must be seekable
private RandomAccessFile m_file = null;
class Entry
{
public int type;
public String filename;
public int length1;
public int length2;
public int offset;
public int flags;
};
public KFNDumper( String fontFilename ) throws IOException
{
m_file = new RandomAccessFile( fontFilename, "r" );
}
public List<Entry> list() throws IOException
{
List<Entry> files = new ArrayList<Entry> ();
// Read the file signature
String signature = new String( readBytes(4) );
if ( !signature.equals("KFNB") )
return new ArrayList<Entry> ();
// Parse the header fields
while ( true )
{
signature = new String( readBytes(4) );
int type = readByte();
int len_or_value = readDword();
switch ( type )
{
case 1:
break;
case 2:
byte[] buf = readBytes( len_or_value );
break;
}
if ( signature.equals("ENDH") )
break;
}
// Read the number of files in the directory
int numFiles = readDword();
// Parse the directory
for ( int i = 0; i < numFiles; i++ )
{
Entry entry = new Entry();
int filenameLen = readDword();
byte[] filename = readBytes( filenameLen );
// This is definitely not correct as the native encoding is used, but that's the best we can come out with
entry.filename = Charset.forName( "UTF-8" ).decode( ByteBuffer.wrap( filename ) ).toString();
entry.type = readDword();
entry.length1 = readDword();
entry.offset = readDword();
entry.length2 = readDword();
entry.flags = readDword();
files.add( entry );
}
// Since all the offsets are based on the end of directory, readjust them
for ( int i = 0; i < files.size(); i++ )
files.get(i).offset += m_file.getFilePointer();
return files;
}
public void extract( final Entry entry, String outfilename ) throws IOException
{
// Seek to the file beginning
m_file.seek( entry.offset );
// Create the output file
FileOutputStream output = new FileOutputStream( outfilename );
byte[] buffer = new byte[8192];
int totalRead = 0;
while ( totalRead < entry.length1 )
{
int toRead = buffer.length;
if ( toRead > entry.length1 - totalRead )
toRead = entry.length1 - totalRead;
int bytesRead = m_file.read( buffer, 0, toRead );
output.write( buffer, 0, bytesRead );
totalRead += bytesRead;
}
output.close();
}
// Helper I/O functions
private int readByte() throws IOException
{
return m_file.read() & 0xFF;
}
private int readDword() throws IOException
{
int b1 = readByte();
int b2 = readByte();
int b3 = readByte();
int b4 = readByte();
return b4 << 24 | b3 << 16 | b2 << 8 | b1;
}
private byte [] readBytes( int length ) throws IOException
{
byte [] array = new byte [ length ];
if ( m_file.read( array ) != length )
throw new IOException();
return array;
}
public static void main( String [] args ) throws Exception
{
if ( args.length == 0 )
{
System.out.println( "Usage: app <KFN file>\n" );
return;
}
KFNDumper kfnfile = new KFNDumper( args[0] );
List<Entry> entries = kfnfile.list();
for ( Entry entry : entries )
{
System.out.println( "File " + entry.filename + ", type: " + entry.type + ", length1: "
+ entry.length1 + ", length2: " + entry.length2 + ", offset: "
+ entry.offset + ", flags: " + entry.flags );
kfnfile.extract( entry, entry.filename );
}
}
}
Скомпилируйте и запустите этот файл и все файлы будут извлечены из контейнера KFN.
Теперь нам нужно выяснить, как кодируются тексты песен. Давайте посмотрим на файл Song.ini
Это довольно просто. Посмотрев на содержимое файла song.ini отчётливо видно где текст песни и где информация о времени синхронизации с мелодией.
Sync0=412,469,524,1009,1041,1069,1194,1244,1297,1413,1466,1498,1544,1935,1957,1984,2152,2185,2213,2356,2380,2409,2499,2839,2867,2902,3089,3142,3299,3332,3468,3530,3584,3785,3815,3942,3981,4012,4198,4242,4673
Sync1=4698,4737,4899,4928,4966,5051,5086,5126,5186,5585,5613,5645,5813,5843,5879,5967,6036,6072,6154,6503,6530,6566,6736,6798,6989,7024,7123,7216,7264,7412,7473,7612,7641,7674,7722,7845,7935,8360,8396,8527,8555
Text0=CHÚA BIẾT RÕ
Text1=
Text2=Ngài biết rõ
Text3=những nhu cầu
Text4=của đời sống tôi
Здесь видно, что время хранится отдельно от текста, и мы должны выяснить способ объединить их. Давайте посчитаем, сколько чисел синхронизации есть всего:
> grep -a ^Sync Song.ini | sed -e 's/,/\n/g' | wc -l
217
Таким образом, мы имеем 217 временных меток и только 79 строк текста. Давайте предположим, что каждая метка времени относится к слову.
Для этого необходимо рассчитать общее количество слов в полях Текст:
grep -ae '^Text[0-9]*= Song.ini | sed -e 's/[ \/]/\n/g' | wc -l
223
Почти правильно, но не совсем. Цифры не совпадают, так как вероятно, некоторые текстовые поля не используют тайминги. Выше мы видим «Text1 =» - пустую строку. Имеет ли смысл иметь метку синхронизации для пустой строки? На самом деле, нет.
Давайте удалим их из расчета:
grep -aE '^Text[0-9]*=\w' Song.ini | sed -e 's/ /\n/g'| wc -l
218
Почти получилось. Давайте попробуем преобразовать Song.ini в файл LRC и воспроизвести его, чтобы проверить правильность наших действий. Остался один вопрос - как кодируется время? Это довольно легко - наибольшее значение времени 26736. Скорее всего это несколько десятков миллисекунд (т.е. разделим на 100, чтобы получить значение в секундах).
Вот скрипт конвертора, написанный на Perl:
#!/usr/bin/perl
use warnings;
use strict;
die "Usage: $0 <file>\n" if !defined $ARGV[0];
open F, "<", $ARGV[0] or die "Couldn't open $ARGV[0]: $!\n";
binmode F, ":utf8";
my @content = <F>;
close F;
# Get the sync info
my (@syncs, @text);
foreach my $line ( @content )
{
# CRLFs
$line =~ s/\r/\n/;
$line =~ s/\n+/\n/;
# Add the sync markers into the sync array
push @syncs, split( /,/, $1 ) if $line =~ /^Sync\d+=(.*)$/;
if ( $line =~ /^Text\d+=(\w+.*)$/ )
{
push @text, split( /[ \/]/, $1 );
push @text, ""; # end of line
}
}
# Print a fake LRC header
binmode STDOUT, ":utf8";
print "[ti: test]\n[ar: test]\n";
my $last_time;
foreach my $word ( @text )
{
if ( $word eq "" )
{
print "[$last_time]\n";
next;
}
# Convert the time to mm:ss.ms
my $time = shift @syncs;
my $min = int ( $time / 6000 );
my $sec = int ( ($time - ($min * 6000)) / 100 );
my $msec = int ( $time - ($min * 6000 + $sec * 100) );
$last_time = "$min:$sec.$msec";
print "[$last_time]$word ";
}
После проверки все работает отлично. Можно сказать, что мы закончили реверс-инженерии формата KFN, и теперь мы можем написать весь набор необходимых утилит или свой собственный проигрыватель или редактор караоке.
Однако осталась одна проблема - как бороться с шифрованием?
Файлы, которые мы исследовали не были зашифрованы. Однако все мы знаем, что есть зашифрованные (заблокированные) KFN-файлы. Но KaraFun проигрыватель воспроизводит эти файлы и не требует каких-либо паролей от пользователя и не требует подключения к Интернету (и, следовательно, не загружает ключи с сервера).
Прежде всего нужно отметить, что реверс-инжинирингом дешифрование формата файла - очень трудная задача. Хотя в нашем случае ключ шифрования встроен в программное обеспечение плеера или находится в самом файле. Однако, как оказалось на деле из-за серьезной ошибки в программном обеспечении KaraFun можно воспроизводить даже зашифрованные файлы!
Filename: UTF8:Ai Về Sông Tương.docx, type #0, len1 18139, offset 0, len2 18144, flags 1
Filename: Ai Ve Song Tuong (Thong Dat) - Chau Sa.mp3, type #2, len1 5903028, offset 18144, len2 5903040, flags 1
Filename: Ben Tre - 1.jpg, type #3, len1 77166, offset 5921184, len2 77168, flags 1
Filename: Bien Galang1 - 1993 - 1.jpg, type #3, len1 192985, offset 5998352, len2 192992, flags 1
Filename: Cau Bach Ho - Hue 1 - 1.jpg, type #3, len1 88864, offset 6191344, len2 88864, flags 1
Filename: Cheo Thuyen Tren Song Huong - 1.jpg, type #3, len1 65977, offset 6280208, len2 65984, flags 1
Filename: Chiec Xuong 3 La - 1.jpg, type #3, len1 76788, offset 6346192, len2 76800, flags 1
Filename: Cua Thuan An - 1.jpg, type #3, len1 55579, offset 6422992, len2 55584, flags 1
Filename: Dan Trau Gam Co - 1.jpg, type #3, len1 88893, offset 6478576, len2 88896, flags 1
Filename: Darling Habour Sydney - 1.jpg, type #3, len1 1229239, offset 6567472, len2 1229248, flags 1
Filename: Em & Trang Mo - 1.jpg, type #3, len1 20623, offset 7796720, len2 20624, flags 1
Filename: Em Che Quat 01 - 1.jpg, type #3, len1 16905, offset 7817344, len2 16912, flags 1
Filename: Em Nhin Nghieng - 1.jpg, type #3, len1 19615, offset 7834256, len2 19616, flags 1
Filename: Eo.S. - starburst 10 aurora distalis.milk, type #6, len1 12964, offset 7853872, len2 12976, flags 1
Filename: Galang1 - 1.jpg, type #3, len1 235096, offset 7866848, len2 235104, flags 1
Filename: Ghe Tren Song Huong - 1.jpg, type #3, len1 104282, offset 8101952, len2 104288, flags 1
Filename: Hong Duong Festival Flower in shopping center 2007 - 1.jpg, type #3, len1 227821, offset 8206240, len2 227824, flags 1
Filename: Ngan truoc Cau Darling Habour Sydney 22.5.10 - 1.jpg, type #3, len1 1666971, offset 8434064, len2 1666976, flags 1
Filename: UVNBayBuomHep_N.TTF, type #4, len1 66248, offset 10101040, len2 66256, flags 1
Filename: Ai Ve Song Tuong (Thong Dat) - Beat.mp3, type #2, len1 5901353, offset 10167296, len2 5901360, flags 1
Filename: Song.ini, type #1, len1 11722, offset 16068656, len2 11728, flags 1
Здесь видно отличие приведенных выше примеров - len1 и len2 различны, а значение флага равно 1.
Все файлы закодированы. Чтобы понять , как плеер KaraFun знает , какой файл шифруется, нам нужно проверить следующую песню:
Filename: end.jpg, type #3, len1 65354, offset 0, len2 65354, flags 0
Filename: begin.jpg, type #3, len1 67380, offset 65354, len2 67380, flags 0
Filename: bg 1.jpg, type #3, len1 68210, offset 132734, len2 68210, flags 0
Filename: bg 2.jpg, type #3, len1 77521, offset 200944, len2 77521, flags 0
Filename: bg 4.jpg, type #3, len1 57592, offset 278465, len2 57592, flags 0
Filename: bg 5.jpg, type #3, len1 82363, offset 336057, len2 82363, flags 0
Filename: bg 6.jpg, type #3, len1 50473, offset 418420, len2 50473, flags 0
Filename: bg 7.jpg, type #3, len1 44052, offset 468893, len2 44052, flags 0
Filename: bg 8.jpg, type #3, len1 69982, offset 512945, len2 69982, flags 0
Filename: An Nan (100 0) - ST - melody - 64 t.mp3, type #2, len1 2574172, offset 582927, len2 2574172, flags 0
Filename: An Nan (100 0) - ST - 128 t.mp3, type #2, len1 5062944, offset 3157099, len2 5062944, flags 0
Filename: y2kboogie.ttf, type #4, len1 43124, offset 8220043, len2 43124, flags 0
Filename: Song.ini, type #1, len1 9524, offset 8263167, len2 9536, flags 1
После разбора второй песни мы сравниваем файлы и видим, что все файлы расшифрованы в то время как Song.ini зашифрован. Теперь ясно, что значение флага указывает на то, является ли файл закодированным или нет.
Теперь нужно выяснить следующие вещи:
- файл был упакован в контейнер и зашифрован или зашифрован и упакован в контейнер;
- если файл был упакован, то какой был использован паковщик;
- если файлы зашифрованы, то с помощью какого ключа шифрования и где он хранятся.
Для того, чтобы сделать первичный анализ мы смотрим на следующие строки из обоих выходов:
Filename: Song.ini, type #1, len1 11722, offset 16068656, len2 11728, flags 1
Filename: Song.ini, type #1, len1 9524, offset 8263167, len2 9536, flags 1
Как мы уже знаем, Song.ini представляет собой текстовый файл. Такие файлы пакуются очень хорошо. Так что, если он был запакован, то len1 должен быть значительно меньше, чем len2. То же справедливо и для TTF-файлов. Шрифты упаковываются очень хорошо. Однако изменения размера настолько минимально, что можно с уверенностью предположить - данный момент файл не упакован, а находится только в зашифрованном виде. Поэтому мы должны выяснить схему шифрования и ключ.
Схема шифрования может быть какая угодно, от XORing содержания с одного байта (который можно было бы угадать , просто посмотрев на файл) в поток/блок симметричного шифрования с помощью встроенного ключа (вывод, который потребует двоичный обратный инжиниринг).
Для начала мы посмотрим на первые 16 байт файлов JPEG. Мы выбираем файлы JPEG, так как они имеют одинаковую сигнатуру в первых 4-8 байт, которые, как правило, выглядит следующим образом:
00000000 ff d8 ff e0 00 10 4a 46 49 46 00 01 01 01 00 48 |......JFIF.....H|
Таким образом , мы запускаем шестнадцатеричные на всех JPG файлов в директории распакованного Ai_biet_ngay_nao_Chua_den :
> for i in *.jpg; do hexdump -C "$i" | head -n 1; done
00000000 bd e5 91 9a cb 27 72 46 b1 fc b1 8a a9 c8 0c c5 |.....'rF........|
00000000 bd e5 91 9a cb 27 72 46 b1 fc b1 8a a9 c8 0c c5 |.....'rF........|
00000000 bd e5 91 9a cb 27 72 46 b1 fc b1 8a a9 c8 0c c5 |.....'rF........|
00000000 bd e5 91 9a cb 27 72 46 b1 fc b1 8a a9 c8 0c c5 |.....'rF........|
00000000 bd e5 91 9a cb 27 72 46 b1 fc b1 8a a9 c8 0c c5 |.....'rF........|
00000000 bd e5 91 9a cb 27 72 46 b1 fc b1 8a a9 c8 0c c5 |.....'rF........|
00000000 bd e5 91 9a cb 27 72 46 b1 fc b1 8a a9 c8 0c c5 |.....'rF........|
00000000 bd e5 91 9a cb 27 72 46 b1 fc b1 8a a9 c8 0c c5 |.....'rF........|
00000000 bd e5 91 9a cb 27 72 46 b1 fc b1 8a a9 c8 0c c5 |.....'rF........|
00000000 bd e5 91 9a cb 27 72 46 b1 fc b1 8a a9 c8 0c c5 |.....'rF........|
00000000 bd e5 91 9a cb 27 72 46 b1 fc b1 8a a9 c8 0c c5 |.....'rF........|
00000000 bd e5 91 9a cb 27 72 46 b1 fc b1 8a a9 c8 0c c5 |.....'rF........|
00000000 bd e5 91 9a cb 27 72 46 b1 fc b1 8a a9 c8 0c c5 |.....'rF........|
00000000 bd e5 91 9a cb 27 72 46 b1 fc b1 8a a9 c8 0c c5 |.....'rF........|
00000000 bd e5 91 9a cb 27 72 46 b1 fc b1 8a a9 c8 0c c5 |.....'rF........|
Что мы можем здесь увидеть?
Во-первых, используемое шифрование не является простым XOR, так как 0xFF по смещению 0 кодируется как 0xBD и 0xFF, а по смещению 3 кодируется как 0x91. Это может быть исключающее ИЛИ со статической таблицы с использованием смещения в виде soul, или симметричный шифр. Он также может быть AES шифрования в режиме CTR, который использует тот же Ctr значение для каждого файла.
Во-вторых, ключ шифрования является одинаковым для каждого зашифрованного файла, и не использует soul для определенных типов файлов, так как все файлы JPEG начинаются с той же подписью.
Теперь давайте взглянем на файл шрифта UVNBayBuomHep_N.TTF (показана часть):
> hexdump -C UVNBayBuomHep_N.TTF | head -n 80 | tail
00000490 0e fb b9 60 33 06 27 dc d9 0e f8 23 ae 23 80 f2 |...`3.'....#.#..|
*
000004b0 f1 52 7f 51 1b 15 3f 02 92 93 6f 11 5c 67 ac 4a |.R.Q..?...o.\g.J|
000004c0 0e fb b9 60 33 06 27 dc d9 0e f8 23 ae 23 80 f2 |...`3.'....#.#..|
*
000004e0 37 c9 fb 30 b8 76 ac 9b dd 40 70 4b d7 01 cf 41 |7..0.v...@pK...A|
000004f0 0e fb b9 60 33 06 27 dc d9 0e f8 23 ae 23 80 f2 |...`3.'....#.#..|
*
000006a0 08 e0 f2 d9 0f a7 7f 82 31 6e 6a 42 5d 5b 08 7f |........1njB][..|
000006b0 76 74 de 72 62 b1 20 8d fc 8c a7 c0 0d fe 01 56 |vt.rb. ........V|
Как видно, та же картина повторяется между 0x4F0 и 0x6A0 (звезда на выходе означает содержание между 0x4f0 и 0x6a0 содержит точно такие же байты). Из опыта - эти области заполняются нулями, но нужно посмотреть что там в исходном файле.
Мы можем сделать это. Этот файл TTF шрифт имеет уникальное имя файла. Это означает, что он должен быть доступен где-то в Интернете. Поиск в Google действительно нашел этот файл, так что мы можем сбросить такой же раздел и посмотреть:
> hexdump -C ~/download/UVNBayBuomHep_R.TTF | head -n 80 | tail
00000490 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
*
000004b0 00 00 00 c6 00 00 00 00 00 00 00 00 00 c7 00 c8 |................|
000004c0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
*
000004e0 00 00 00 00 00 00 00 c9 00 00 00 00 00 00 00 00 |................|
000004f0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
*
000006a0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ca |................|
000006b0 00 cb 00 00 00 cc 00 00 00 00 00 00 00 00 00 00 |................|
Нули стоят, как и ожидалось. Но есть намного больше информации. Можно увидеть, что все 16-байтовые блоки содержащие все нули кодируются в одних и тех же образцах, в то время как блоки, имеющие одно ненулевое значение, например, одно на 0x4E0 кодируются в совершенно другой образец. Однако следующий блок нулей кодируется снова точно так же, как и раньше.
Это позволяет нам сделать несколько очень важных выводов:
- Шифрование используется действительно симметричный шифр, таких как Blowfish, AES, DES, RC2 и т.д. Это не 16-байт XOR, потому что если вы восстановите ключ - на основе последовательности нулей, ключ шифрования должен быть «0e fb b9 60 33 06 27 dc d9 0e f8 23 ae 23 80 f2”, но применение этого ключа в файл JPEG не дает действительную JFIF подпись, и поэтому мы можем исключить эту возможность.
- Шифрование использует 16 байт (128 бит) блоки. Далее мы можем подтвердить это, посмотрев на разницу между len1 решила и len2. Для блочного шифрования содержимое файла должно быть дополнено, чтобы соответствовать размеру блока. Как мы можем видеть, все значения len2 делятся на 16, а len1 значения не являются. Поэтому можно предположить, что для зашифрованных файлов, которые мы должны прочитать - len2 байты для расшифровки, но размер файла на диске должен быть - len1 с лишними байтами обивки.
- Шифрование используется в режиме ECB http://en.wikipedia.org/wiki/Block_ciph … operation, где каждый блок кодируется отдельно. Таким образом, содержание одного блока не оказывает никакого влияния на другие блоки, и все 16-байтовых блоков с одинаковым содержанием будет закодирован таким же образом. Это полезно для форматов хранения "в-файл", поскольку он позволяет произвольный доступ к контенту, но это - слабый способ шифрования. Это и была роковая ошибка разработчика шифрованного KFN-формата, которая позволила легко разгадать способ шифрования.
Сочетание 128-битных блоков в режиме ECB может ограничиться меньшим числом алгоритмов, которые имеют встроенные ECB-128 режимы.
Теперь нужно найти хранится ли ключ в KFN-файле, или в плеере KaraFun. Для этого мы рассмотрим зашифрованные файлы, извлеченные из различных файлов KFN, и посмотрим, чем они отличаются. Однако плеер KFN способен воспроизводить все эти файлы без запроса пароля. Поэтому, делаем вывод о том, что ключ не хранится в плеере, а хранится в самом файле KFN. Мы можем найти его с помощью сканирования.
Итак, делаем следующие предположения: файл зашифрован по алгоритму AES в режиме ECB с использованием 128-битных блоков и 128-битовый ключ. И ключ хранится где-то в файле.
Сначала выясним, какие байты должны совпадать с ключом:
> hexdump -n 8 -C ~/download/UVNBayBuomHep_R.TTF > out.match
После расшифровки должным образом, первые 8 байт из файла шрифта должен соответствовать шифру. Чтобы ускорить расшифровку, давайте скопируем первые 64 байта из зашифрованного файла шрифта:
> dd if=UVNBayBuomHep_N.TTF of=encrypted.dat bs=1 count=64
64+0 records in
64+0 records out
64 bytes (64 B) copied, 0.000496502 s, 129 kB/s
Теперь мы можем создать скрипт, который пытается найти ключ:
# The 116081787 is size of the KFN file
i=0
while [ $i -lt 16081787 ]; do
# Generate the key by hexdumping the 16 bytes of the file on a specific offset $i
key=`hexdump -s $i -n 16 -e '1/1 "%02X"' ai.kfn`
# Try to decrypt with OpenSSL using AES-128-ECB. Ignore OpenSSL decrypt errors.
openssl enc -in encrypted.dat -out test.dat -d -aes-128-ecb -K "$key" > /dev/null 2>&1
# Hexdump the first 8 bytes of the result and compare it with what's expected
hexdump -C -n 8 test.dat > out.hex
diff=`diff out.hex out.match`
if [ -z "$diff" ]; then
echo "Key $key found at offset $i"
exit
fi
i=$[$i + 1]
done
Как вы видите, сценарий просто пытается использовать 16 байтов из файла в качестве ключа AES, начиная от первого смещения 0, затем 1 и так далее.
Давайте запустим его:
> sh bruteforce.sh
Key 7D64DEA5E1BE5DD4FC7ED23F78C8D8DF found at offset 76
Таким образом, все наши предположения были верны - файл ключа находится в файл, и он использует AES ECB шифрование с 128-битными блоками.
Давайте попробуем этим ключом расшифровать другой файл, например, Song.ini:
openssl enc -in Song.ini -out Song.out -d -aes-128-ecb \
-K 7D64DEA5E1BE5DD4FC7ED23F78C8D8DF
и проверим Song.out. Таким образом, мы можем сделать вывод, что ключ этот верный и будет работать со всеми файлами.
Остаётся открытым единственный вопрос - ключ всегда хранится по смещению 76 (который 0x4C)? Чтобы узнать это сделаем HexDump файла по этому смещению:
> hexdump -C ai.kfn | head
00000000 4b 46 4e 42 44 49 46 4d 01 03 00 00 00 44 49 46 |KFNBDIFM.....DIF|
00000010 57 01 03 00 00 00 47 4e 52 45 01 ff ff ff ff 53 |W.....GNRE.....S|
00000020 46 54 56 01 53 00 12 01 4d 55 53 4c 01 71 01 00 |FTV.S...MUSL.q..|
00000030 00 41 4e 4d 45 01 0d 00 00 00 54 59 50 45 01 00 |.ANME.....TYPE..|
00000040 00 00 00 46 4c 49 44 02 10 00 00 00 7d 64 de a5 |...FLID.....}d..|
00000050 e1 be 5d d4 fc 7e d2 3f 78 c8 d8 df 54 49 54 4c |..]..~.?x...TITL|
00000060 02 1f 00 00 00 41 69 20 56 e1 bb 81 20 53 c3 b4 |.....Ai V... S..|
00000070 6e 67 20 54 c6 b0 c6 a1 6e 67 20 28 43 6c 61 73 |ng T....ng (Clas|
00000080 73 69 63 29 41 52 54 53 02 08 00 00 00 43 68 c3 |sic)ARTS.....Ch.|
00000090 a2 75 20 53 61 41 4c 42 4d 02 0d 00 00 00 4b 66 |.u SaALBM.....Kf|
Вывод - наш ключ хранится, начиная от 0x4c в заголовке файла под FLID.
Таким образом, логика загрузчика должна быть следующая:
- Разбираем FLID и получаем ключ (нешифрованные файлы, как правило, имеют там нули);
- Если флаги в файле отличны от нуля, читать длину файла, и расшифровать его с AES-ECB-128 с помощью ключа от FLID
- Написать расшифрованные данные на диск до реальной длины файла и отбросить остаток
Изменим наш Java-распаковщик, чтобы добавить поддержку шифрования:
import java.io.File;
import java.io.IOException;
import java.io.FileOutputStream;
import java.io.FileNotFoundException;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.List;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.security.GeneralSecurityException;
import javax.crypto.spec.IvParameterSpec;
class KFNDumper
{
public static final int TYPE_SONGTEXT = 1;
public static final int TYPE_MUSIC = 2;
public static final int TYPE_IMAGE = 3;
public static final int TYPE_FONT = 4;
public static final int TYPE_VIDEO = 5;
// The KFN file
private RandomAccessFile m_file = null;
// The file decryptor, if known
private Cipher m_decryptor = null;
// A directory entry
class Entry
{
public int type; // the file type; see TYPE_
public String filename; // the original file name in the original encoding
public int length_in; // the file length in the KFN file
public int length_out; // the file lenght on disk; if the file is encrypted it is the same or smaller than length_in
public int offset; // the file offset in the KFN file starting from the directory end
public int flags; // the file flags; 0 means "not encrypted", 1 means "encrypted"
};
public KFNDumper( String fontFilename ) throws IOException
{
m_file = new RandomAccessFile( fontFilename, "r" );
}
public List<Entry> list() throws IOException, GeneralSecurityException
{
List<Entry> files = new ArrayList<Entry> ();
// Read the file signature
String signature = new String( readBytes(4) );
if ( !signature.equals("KFNB") )
return new ArrayList<Entry> ();
// Parse the header fields
while ( true )
{
signature = new String( readBytes(4) );
int type = readByte();
int len_or_value = readDword();
byte[] buf = null;
switch ( type )
{
case 1:
break;
case 2:
buf = readBytes( len_or_value );
break;
}
// Store the AES key if we have it
if ( signature.equals("FLID") && buf != null )
{
SecretKeySpec keyspec = new SecretKeySpec( buf, "AES" );
m_decryptor = Cipher.getInstance("AES/ECB/NoPadding");
m_decryptor.init( Cipher.DECRYPT_MODE, keyspec );
}
if ( signature.equals("ENDH") )
break;
}
// Read the number of files in the directory
int numFiles = readDword();
// Parse the directory
for ( int i = 0; i < numFiles; i++ )
{
Entry entry = new Entry();
int filenameLen = readDword();
byte[] filename = readBytes( filenameLen );
// This is definitely not correct as the native encoding is used, but that's the best we can come out with
entry.filename = Charset.forName( "UTF-8" ).decode( ByteBuffer.wrap( filename ) ).toString();
entry.type = readDword();
entry.length_out = readDword();
entry.offset = readDword();
entry.length_in = readDword();
entry.flags = readDword();
files.add( entry );
}
// Since all the offsets are based on the end of directory, readjust them
for ( int i = 0; i < files.size(); i++ )
files.get(i).offset += m_file.getFilePointer();
return files;
}
public void extract( final Entry entry, String outfilename ) throws IOException, GeneralSecurityException
{
// Prepare the decryptor if we have it
if ( (entry.flags & 0x01) != 0 && m_decryptor == null )
throw new IOException("Key is unknown");
// Seek to the file beginning
m_file.seek( entry.offset );
// Create the output file
FileOutputStream output = new FileOutputStream( outfilename );
byte[] buffer = new byte[8192]; // size of the buffer must be a multiple of 16
int total = 0;
while ( total < entry.length_in )
{
int toRead = buffer.length;
if ( toRead > entry.length_in - total )
toRead = entry.length_in - total;
int bytesRead = m_file.read( buffer, 0, toRead );
if ( (entry.flags & 0x01) != 0 )
{
byte [] decrypted = m_decryptor.doFinal( buffer );
// We might need to write less than we read since the file is rounded to 16 bytes
int toWrite = bytesRead;
if ( total + toWrite > entry.length_out )
toWrite = entry.length_out - total;
output.write( decrypted, 0, toWrite );
}
else
output.write( buffer, 0, bytesRead );
total += bytesRead;
}
output.close();
}
// Helper I/O functions
private int readByte() throws IOException
{
return m_file.read() & 0xFF;
}
private int readDword() throws IOException
{
int b1 = readByte();
int b2 = readByte();
int b3 = readByte();
int b4 = readByte();
return b4 << 24 | b3 << 16 | b2 << 8 | b1;
}
private byte [] readBytes( int length ) throws IOException
{
byte [] array = new byte [ length ];
if ( m_file.read( array ) != length )
throw new IOException();
return array;
}
public static void main( String [] args ) throws Exception
{
if ( args.length == 0 )
{
System.out.println( "Usage: app <KFN file>\n" );
return;
}
KFNDumper kfnfile = new KFNDumper( args[0] );
List<Entry> entries = kfnfile.list();
for ( Entry entry : entries )
{
System.out.println( "File " + entry.filename + ", type: " + entry.type
+ ", length_in: " + entry.length_in + ", length_out: "
+ entry.length_out + ", offset: " + entry.offset + ", flags: " + entry.flags );
kfnfile.extract( entry, entry.filename );
}
}
}
Вот и все.