最近研究了一下梦幻西游手游版的资源打包方式其中就用到了Hash表索引
0x00 先看看梦幻西游手游的资源目录
┌─HashRes
┊ ├─00
┊ ├─000000
┊ ├─ ┊
┊ ├─FFFFFF
┊ ├─01
┊ ├─┊
┊ ├─FF
HashRes中的文件夹名称和子文件名组合成了一个4字节的Hash名,如下图反编译so文件后看到的寻找资源的Hash路径
由于Hash不可逆,且没有资源文件名单,文件名就用hash表示吧
0x01 在HashRes文件夹里,通过对文件头的分析主要的有大致几类文件,其它类型可以忽略
&&__sign_of_g18_enc__@@(加密的图片文件,有的用了Lzma压缩)
L:grxx__sign_of_g18_enc__(加密的luac脚本文件,有的用了Gzip压缩)
__sign_of_g18_enc__(加密的luac脚本文件)
LuaQ(luac脚本文件)
FSB5(音频文件)
JSON (Json文件)
XML (xml配置文件)
手游版的资源和网页版的资源相似,略有不同但加密方式是相同的
0x02 先分析图片是怎样加载的,在IDA中反汇编中定位到cocos2d::Image::initWithImageData()这个方法
在解密后,紧接着判断了该图片是否压缩了,如果压缩了就解压缩,ccz和gzip是coocs2dx源码中本来就有的,lzma是梦幻后加的
继续分析,解压后先通过cocos2d::Image::detectFormat()这个方法判断是什么类型的图片,然后根据类型加载这个图片
整个图片资源的加载流程是
0x03 lua的加载比图片加载稍微复杂一丢丢,定位到cocos2d::LuaStack::luaLoadBuffer()这个方法
首先注意的是strncmp ( const char * str1, const char * str2, size_t n )的返回值:若str1与str2的前n个字符相同,则返回0
lrc4这个类就是解密lua的
整个lua资源的加载流程是
上面这个图左侧部分是指 L:grxx__sign_of_g18_enc__ 右侧部分是指 __sign_of_g18_enc__
最终得到的lua资源文件为lua编译过的二进制文件,并不是lua源码,想得到源码就得反编译luac文件。
0x04 在反编译luac之前,先来分析下lua5.1.4(梦幻西游手游用的是这个版本)源码中是如何加载的
lua5.1.4 源码下载地址 http://www.lua.org/ftp/
通过分析源码可知
#define LUA_SIGNATURE "\033Lua" 33(八进制) = 0x1B(十六进制)
在f_parser这个方法中 通过判断lua文件的第一个字节是否为LUA_SIGNATURE[0]也就是0x1B
若是0x1B那么读取的数据是Binary(二进制luac) 调用luaU_undump,否则为Text(源码) 调用luaY_parser,它们最终都会返回一个Proto*类型。
下面分析一下lua5.1 二进制格式 由两部分组成:头部块和顶层函数
头部块包含12字节
头部签名 | 4字节 | 0x1B 0x4C 0x75 0x61 |
版本号 | 1字节 | 0x51 (高十六位是主版本号,低十六位是次版本号) |
版本格式 | 1字节 | 0x00 (0=官方版本) |
字节序标志 | 1字节 | 0x01 (默认为1 1=大端 0=小端) |
int大小 | 1字节 | 0x04 (默认为4 单位为字节) |
size_t大小 | 1字节 | 0x04 (默认为4 单位为字节) |
Instruction大小 | 1字节 | 0x04 (默认为4 单位为字节) |
lua_Number大小 | 1字节 | 0x08 (默认为8 单位为字节) |
整数标志 | 1字节 | 0x00 (默认为0 0=浮点数 1=整数) |
顶层函数(持有函数的所有相关数据 关于列表的详细信息这里就不展示了)
源代码名称长度(size_t) | 4字节 | 例如 0x08 0x00 0x00 0x00 长度为8 |
源代码名称 | size_t字节 | 例如 0x40 0x64 0x62 0x2E 0x6C 0x75 0x61 0x00 以0x00结尾 |
定义开始行(int) | 4字节 | 0x00 0x00 0x00 0x00 (主代码块默认为 0) |
定义结束行(int) | 4字节 | 0x00 0x00 0x00 0x00 (主代码块默认为 0) |
upvalue数量 | 1字节 | 0x00 (主代码块默认为 0) |
参数数量 | 1字节 | 0x00 (主代码块默认为 0) |
is_varagr标志 | 1字节 | 1=VARARG_HASARG 2=VARARG_ISVARARG 4=VARARG_NEEDSARG |
最大栈尺寸 | 1字节 | 使用的寄存器数量 |
指令列表 | [指令大小] [虚拟机指令] | |
常量列表 | [常量大小] [常量类型 常量值] | |
函数原型列表 | [函数原型列表大小] [函数原型数据] | |
源码位置列表 | [源码位置列表大小] [表索引对应指令位置] 可选的调试数据 | |
局部变量列表 | [局部变量列表大小] [局部变量名 作用域起点 作用域终点] 可选的调试数据 | |
upvalue列表 | [upvalue列表大小] [upvalue的名字] 可选的调试数据 |
关于luac的反编译工具,网上开源的代码有
luadec51 (C++) 下载地址 https://github.com/sztupy/luadec51 (有进行变量分析,但少了很多模式匹配,很容易出错)
luadec (C++) 下载地址 https://github.com/viruscamp/luadec (属于luadec51的分支)
unluac (Java) 下载地址 https://github.com/viruscamp/unluac (当程序有调试符号时,它是最好的选择,但它并没有进行变量分析)
LuaAssemblyTools (lua) 下载地址 https://github.com/mlnlover11/LuaAssemblyTools
一般的luac文件反编译工作到此就结束了,可梦幻西游手游的luac文件不是一般的luac,直接用上面的工具肯定会报错
这是因为梦幻西游手游版修改了lua虚拟机中的opcode(字节码)
lua5.1.4 | 梦幻西游 | |
OP_MOVE | 0 | 25 |
OP_LOADK | 1 | 19 |
OP_LOADBOOL | 2 | 9 |
OP_LOADNIL | 3 | 0 |
OP_GETUPVAL | 4 | 22 |
OP_GETGLOBAL | 5 | 28 |
OP_GETTABLE | 6 | 20 |
OP_SETGLOBAL | 7 | 26 |
OP_SETUPVAL | 8 | 30 |
OP_SETTABLE | 9 | 15 |
OP_NEWTABLE | 10 | 5 |
OP_SELF | 11 | 27 |
OP_ADD | 12 | 33 |
OP_SUB | 13 | 1 |
OP_MUL | 14 | 29 |
OP_DIV | 15 | 11 |
OP_MOD | 16 | 13 |
OP_POW | 17 | 23 |
OP_UNM | 18 | 2 |
OP_NOT | 19 | 31 |
OP_LEN | 20 | 6 |
OP_CONCAT | 21 | 34 |
OP_JMP | 22 | 35 |
OP_EQ | 23 | 36 |
OP_LT | 24 | 17 |
OP_LE | 25 | 7 |
OP_TEST | 26 | 16 |
OP_TESTSET | 27 | 4 |
OP_CALL | 28 | 21 |
OP_TAILCALL | 29 | 18 |
OP_RETURN | 30 | 12 |
OP_FORLOOP | 31 | 14 |
OP_FORPREP | 32 | 10 |
OP_TFORLOOP | 33 | 24 |
OP_SETLIST | 34 | 8 |
OP_CLOSE | 35 | 32 |
OP_CLOSURE | 36 | 3 |
OP_VARARG | 37 | 37 |
至于什么是lua虚拟机的opcode 自己百度谷歌吧 我就不讲解了...
但是如何在IDA中寻找opcode可以和大家分享一下
第一种:通过上面对lua_load的分析,在IDA中直接定位lua_load然后一直跟到f_parser进入luaU_undump→LoadFunction→luaG_checkcode→symbexec,在symbexec中有个switch的循环里面有部分的opcode,通过和源码中的逻辑比对找出对应的opcode
第二中:在lua源码lvm.c中有个luaV_execute方法,其中的switch的循环里面有所有所对应的opcode。可以通过lua_call→luaD_call→luaV_execute定位该方法,通过和源码中的逻辑比对找出对应的opcode
建议第一种和第二种一起使用
若大家有更好的方法,欢迎分享,可以在评论中回复
最后把反编译源代码中默认的opcode顺序修改成得到的opcode顺序,然后编译工具。(注意luadec或luadec51还要修改lua源码lopcodes.c中luaP_opmodes里面的顺序 )
0x05 根据上面的分析后,我用C#写了个提取工具,这里只给出关键代码
提取和回写流程逻辑片段
public void FindFile(string dirPath, OperationType type) //参数dirPath为指定的目录
{
DirectoryInfo Dir = new DirectoryInfo(dirPath);
try
{
//查找子目录
foreach (DirectoryInfo d in Dir.GetDirectories())
{
FindFile(Dir + "\\" + d.ToString(), type);
}
//查找文件
foreach (FileInfo f in Dir.GetFiles("*.*"))
{
if (type == OperationType.Decrypt) //解密资源
ReadRes(f);
else if (type == OperationType.Encrypt) //回写资源
ExportRes(f);
else
return;
}
}
catch (Exception ex)
{
MessageBox.Show(ex.Message);
}
}
//写入资源文件
private void ExportRes(FileInfo f)
{
FileStream inStream = new FileStream(f.FullName, FileMode.Open, FileAccess.ReadWrite);
byte[] bytes = new byte[inStream.Length];
inStream.Read(bytes, 0, bytes.Length);
inStream.Close();
if (FileFormat.IsLUAQ(bytes))
{
byte[] gzipBytes = Compress.GzipCompress(bytes); //Gzip压缩
byte[] lrcBytes = LRC4_S(gzipBytes); //LRC4加密
byte[] headBytes = Encoding.Default.GetBytes("L:grxx__sign_of_g18_enc__");
byte[] resBytes = new byte[headBytes.Length + lrcBytes.Length];
Array.Copy(headBytes, 0, resBytes, 0, headBytes.Length);
Array.Copy(lrcBytes, 0, resBytes, headBytes.Length, lrcBytes.Length);
OutResFile(f, resBytes, string.Empty);
}
}
//提取资源文件
private void ReadRes(FileInfo f)
{
FileStream inStream = new FileStream(f.FullName, FileMode.Open, FileAccess.ReadWrite);
byte[] resBytes;
byte[] bytes = new byte[inStream.Length];
inStream.Read(bytes, 0, bytes.Length);
inStream.Close();
if (Encoding.Default.GetString(bytes).Contains("&&__sign_of_g18_enc__@@"))
{
byte[] outBytes;
ImageResDecrypt(bytes, out outBytes);
if (Encoding.Default.GetString(outBytes).Contains("LZMA"))
{
byte[] targetBytes = new byte[outBytes.Length - 4];
Array.Copy(outBytes, 4, targetBytes, 0, outBytes.Length - 4);
resBytes = CheckCompress(targetBytes);
}
else
{
resBytes = CheckCompress(outBytes);
}
}
else if (Encoding.Default.GetString(bytes).Contains("L:grxx"))
{
byte[] targetBytes = new byte[bytes.Length - 25];
Array.Copy(bytes, 25, targetBytes, 0, bytes.Length - 25);
resBytes = CheckCompress(LRC4_S(targetBytes));
}
else if (Encoding.Default.GetString(bytes).Contains("__sign_of_g18_enc__"))
{
byte[] targetBytes = new byte[bytes.Length - 19];
Array.Copy(bytes, 19, targetBytes, 0, bytes.Length - 19);
resBytes = CheckCompress(LRC4_S(targetBytes));
}
else
{
resBytes = bytes;
}
OutResFile(f, resBytes, FileFormat.GetExtension(resBytes));
}
//检测是否压缩
private byte[] CheckCompress(byte[] bytes)
{
if (FileFormat.CheckFormat(bytes) == FileType.LZMA)
return Compress.LZMADecompress(bytes);
else if (FileFormat.CheckFormat(bytes) == FileType.GZip)
return Compress.GzipDecompress(bytes);
else
return bytes;
}
//保存文件
private void OutResFile(FileInfo f, byte[] bytes, string extension)
{
string outPath = Path.Combine(f.DirectoryName, Path.GetFileNameWithoutExtension(f.FullName) + extension);
using (FileStream outStream = new FileStream(outPath, FileMode.OpenOrCreate, FileAccess.ReadWrite))
{
outStream.Seek(0, SeekOrigin.Begin);
outStream.Write(bytes, 0, bytes.Length);
}
}
//图片资源解密算法
private void ImageResDecrypt(byte[] sourceBytes, out byte[] resBytes)
{
byte[] targetBytes = new byte[sourceBytes.Length - 23];
Array.Copy(sourceBytes, 23, targetBytes, 0, sourceBytes.Length - 23);
int length = targetBytes.Length < 128 ? targetBytes.Length : 128;
for (int i = 0; i < length; i++)
{
targetBytes[i] = (byte)(targetBytes[i] ^ (i - 2));
}
resBytes = targetBytes;
if (Encoding.Default.GetString(targetBytes).Contains("&&__sign_of_g18_enc__@@"))
{
//递归是因为在ios版本中有的图片被重复加密了好几次 - -||
ImageResDecrypt(targetBytes, out resBytes);
}
}
//初始化LRC4
private byte[] LRC4()
{
byte[] bytes = new byte[256];
int v1 = 0;
for (int i = 0; i < 256; i++)
{
bytes[i] = (byte)i;
}
for (int i = 0; i < 256; i++)
{
v1 = (int)(v1 + bytes[i] + ((0x9E3779B9 ^ (i >> 2)) >> 8 * (i & 3)));
byte[] b = BitConverter.GetBytes(v1);
if (i != b[0])
{
bytes[i] ^= bytes[b[0]];
bytes[b[0]] = (byte)(bytes[i] ^ bytes[b[0]]);
bytes[i] ^= bytes[b[0]];
}
}
return bytes;
}
//解密LRC4
private byte[] LRC4_S(byte[] bytes)
{
byte[] lrc = LRC4();
byte last = 0;
for (int i = 0; i < bytes.Length; i++)
{
int index = (i + 1) % 256;
byte[] v1 = BitConverter.GetBytes(lrc[index] + last);
last = v1[0];
if (index != last)
{
lrc[index] = (byte)(lrc[index] ^ lrc[last]);
byte v2 = (byte)(lrc[index] ^ lrc[last]);
lrc[last] = v2;
lrc[index] ^= v2;
}
byte[] v3 = BitConverter.GetBytes(lrc[last] + lrc[index]);
bytes[i] = (byte)(lrc[v3[0]] ^ bytes[i]);
}
return bytes;
}
解压缩算法片段
//压缩Gzip文件
public static byte[] GzipCompress(byte[] bytes)
{
byte[] result = null;
using (MemoryStream inStream = new MemoryStream(bytes))
{
using (MemoryStream outStream = new MemoryStream())
{
using (GZipOutputStream gZipOutputStream = new GZipOutputStream(outStream))
{
gZipOutputStream.Write(bytes, 0, bytes.Length);
}
result = outStream.ToArray();
}
}
return result;
}
//解压Gzip文件
public static byte[] GzipDecompress(byte[] bytes)
{
byte[] result;
using (MemoryStream inStream = new MemoryStream(bytes))
{
using (GZipInputStream gZipInputStream = new GZipInputStream(inStream))
{
using (MemoryStream outStream = new MemoryStream())
{
byte[] array = new byte[4096];
int num;
while ((num = gZipInputStream.Read(array, 0, array.Length)) != 0)
{
outStream.Write(array, 0, num);
}
result = outStream.ToArray();
}
}
}
return result;
}
//解压LZMA文件
public static byte[] LZMADecompress(byte[] bytes)
{
byte[] result;
using (MemoryStream inStream = new MemoryStream(bytes))
{
using (MemoryStream outStream = new MemoryStream())
{
Decoder coder = new Decoder();
byte[] properties = new byte[5];
inStream.Read(properties, 0, 5);
byte[] fileLengthBytes = new byte[8];
inStream.Read(fileLengthBytes, 0, 8);
long fileLength = BitConverter.ToInt64(fileLengthBytes, 0);
coder.SetDecoderProperties(properties);
coder.Code(inStream, outStream, inStream.Length, fileLength, null);
result = outStream.ToArray();
}
}
return result;
}
注意在Android平台下纹理图片格式为PKM,iOS平台下纹理图片格式为PVR
FSB音频可以用FsbExtractor软件提取
PKM格式文件可以用Mali Texture Compression Tool软件中的etcpack.exe进行批处理转换成png
PVR格式文件可以用TexturePacker软件中的TexturePacker.exe(需要破解版)进行批处理转换成png
PKM转PNG(path路径改为自己的etcpack.exe所在路径)
@echo off
path %path%;"D:\Program Files\ARM\Mali Developer Tools\Mali Texture Compression Tool\bin"
for /f "usebackq tokens=*" %%d in (`dir /s /b *.pkm`) do (
etcpack.exe "%%d" . -f RGBA8 -ext PNG
)
pause
PVR转PNG(path路径改为自己的TexturePacker.exe所在路径)
@echo off
path %path%;"D:\Program Files\CodeAndWeb\TexturePacker\bin"
for /f "usebackq tokens=*" %%d in (`dir /s /b *.pvr *.pvr.ccz *.pvr.gz`) do (
TexturePacker.exe "%%d" --sheet "%%~dpnd.png" --data "%%~dpnd.plist" --opt RGBA8888 --allow-free-size --algorithm Basic --no-trim --dither-fs
::需要翻转图片 就把下面的::去掉
::NConvert.exe -out png -yflip "%%~dpnd.png"
)
pause
需要TexturePacker.exe,NConvert.exe,etcpack.exe的可以去网盘下载
链接:http://pan.baidu.com/s/1eRKjsbg 密码: h332
资源提取工具下载
链接: http://pan.baidu.com/s/1bo8j1Rx 密码: f257