【手游】梦幻西游手游 美术资源加密分析

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/BlueEffie/article/details/50971665

最近研究了一下梦幻西游手游版的资源打包方式其中就用到了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

猜你喜欢

转载自blog.csdn.net/BlueEffie/article/details/50971665