查看原文
其他

旅行的青蛙Unity游戏逆向修改Android&iOS

2018-02-08 AloneMonkey 看雪学院



前言


最近旅行的青蛙甚火,便来试一试Android和iOS上面Unity3D游戏C#脚本和C/C++函数的分析与修改,由于Android大部分是mono模式如果C#脚本没有加密的话是直接可以拿过来分析修改的,但是iOS大都使用IL2CPP模式把C#脚本转成C/C++代码了,直接分析的话要难很多,所以本文先从Android开始分析然后结合Android再分析iOS。



C#脚本


首先将Android apk安装包解压,查看assets/bin/Data/Managed文件夹下面的dll文件,其中Assembly-CSharp.dll便是游戏中的C#脚本,使用Reflector或者dnSpy进行反编译都可以,如何出现反编译出错的情况,那可能就是脚本被加密了,一般在尝试在libmono.so中的 mono_image_open_from_data_with_name 中获取解密后的dll脚本,而当前分析的游戏C#脚本并没有加密。在dnSpy中的反编译结果如下:

 

 

左侧就可以看到对应的类,点击类就可以看到反编译出来的代码,下面先跟游戏里面的一些特征来分析反编译出来的脚本。



汉化


首先使用 adb install tabikaeru.apk 命令安装apk文件,然后打开游戏可以看到如下界面:

 

 

首先来看看怎么修改显示的文字以达到汉化的效果,使用dnSpy点击编辑->搜索程序集搜索右边选择数字/字符串然后搜索名前:

 

 

点击CallTutorial可以找到刚刚在屏幕上面显示的文字:

 

 

鼠标点击右键选择编辑IL指令,修改刚刚看到的文字为汉字:

 

 

然后点击应用即可看到修改后的效果:

 

 

点击文件->全部保存替换 assets/bin/Data/Managed 文件夹下面对应的文件即可,然后将文件打包成apk使用jarsinger、signapk.jar或Android助手重签名安装到手机就能看到修改后的效果了。

 



修改三叶草数


要修改三叶草数可以从购买的时候入手,比如提示三叶草不足的时候:

 

 

直接搜索足找到目标代码:

 

 

根据找到的如下代码来分析:

if (SuperGameMaster.CloverPointStock() >= itemDataFormat.price)

{

    if (SuperGameMaster.FindItemStock(shopDataFormat.itemId) < 99)

    {

        base.GetComponent<FlickCheaker>().stopFlick(true);

        ConfilmPanel confilm = this.ConfilmUI.GetComponent<ConfilmPanel>();

        if (itemDataFormat.type == Item.Type.LunchBox)

        {

            confilm.OpenPanel_YesNo(string.Concat(new object[]

            {

                itemDataFormat.name,

                "\nを買いますか?\n(所持数\u3000",

                SuperGameMaster.FindItemStock(shopDataFormat.itemId),

                ")"

            }));

        }

        else

        {

            confilm.OpenPanel_YesNo(itemDataFormat.name + "\nを買いますか?");

        }

        confilm.ResetOnClick_Yes();

        confilm.SetOnClick_Yes(delegate

        {

            confilm.ClosePanel();

        });

        confilm.SetOnClick_Yes(delegate

        {

            this.GetComponent<FlickCheaker>().stopFlick(false);

        });

        confilm.SetOnClick_Yes(delegate

        {

            this.BuyItem();

        });

        confilm.ResetOnClick_No();

        confilm.SetOnClick_No(delegate

        {

            confilm.ClosePanel();

        });

        confilm.SetOnClick_No(delegate

        {

            this.GetComponent<FlickCheaker>().stopFlick(false);

        });

    }

    else

    {

        base.GetComponent<FlickCheaker>().stopFlick(true);

        ConfilmPanel confilm = this.ConfilmUI.GetComponent<ConfilmPanel>();

        confilm.OpenPanel("もちものがいっぱいです");

        confilm.ResetOnClick_Screen();

        confilm.SetOnClick_Screen(delegate

        {

            confilm.ClosePanel();

        });

        confilm.SetOnClick_Screen(delegate

        {

            this.GetComponent<FlickCheaker>().stopFlick(false);

        });

    }


当三叶草的存储小于当前商品的价格的时候就会弹出这个提示,点击SuperGameMaster.CloverPointStock()得到如下代码:


public static int CloverPointStock()

{

    return SuperGameMaster.saveData.CloverPoint;

}


这里返回的是一个从saveData.CloverPoint中获取的整数,可以尝试将其修改成返回一个特定的数字每次查询三叶草的数目都是这么多,右键点击编辑IL指令,然后点击重置并右键删除第一条指令,因为只是返回一个数字的话两条指令就够了:

 

 

然后修改第二条指令为一个数字,具体的指令代表的类型可以查看IL指令说明:

 

 

点击确定后就能看到修改代码的效果:

public static int CloverPointStock()

{

    return 9999;

}


重新签名安装之后每次购买商品之后三叶草的数目也不会减少,一直是9999。



修改抽奖券


修改抽奖券也是同样的套路,搜索足找到目标代码,其实刚刚搜索的时候就已经看到了。

 

if (SuperGameMaster.TicketStock() < 5)

{

    ConfilmPanel confilm = this.ConfilmUI.GetComponent<ConfilmPanel>();

    confilm.OpenPanel("ふくびき券が足りません");

    confilm.ResetOnClick_Screen();

    confilm.SetOnClick_Screen(delegate

    {

        confilm.ClosePanel();

    });

    return;

}


点击 SuperGameMaster.TicketStock() 也是差不多的代码同样修改返回数字即可:

public static int TicketStock()

{

    return SuperGameMaster.saveData.ticket;

}


public static int TicketStock()

{

    return 9999;

}



修改抽奖概率


每次抽奖都是抽到白球就不是很开心了,怎么能够提供抽奖的概率呢?抽到其它颜色的球的时候会提示:

 

 

搜索白玉找到如下代码:

public static readonly Dictionary<Rank, string> PrizeBallName = new Dictionary<Rank, string>

{

    {

        Rank.White,

        "白玉"

    },

    {

        Rank.Blue,

        "青玉"

    },

    {

        Rank.Green,

        "緑玉"

    },

    {

        Rank.Red,

        "赤玉"

    },

    {

        Rank.Gold,

        "黄玉"

    }

};


右键点击PrizeBallName选择分析找到在那里被使用的:

 

 

双击PrizeBallName得到如下代码:


public void PushRollButton()

{

    if (SuperGameMaster.TicketStock() < 5)

    {

        ConfilmPanel confilm = this.ConfilmUI.GetComponent<ConfilmPanel>();

        confilm.OpenPanel("ふくびき券が足りません");

        confilm.ResetOnClick_Screen();

        confilm.SetOnClick_Screen(delegate

        {

            confilm.ClosePanel();

        });

        return;

    }

    SuperGameMaster.GetTicket(-5);

    SuperGameMaster.set_FlagAdd(Flag.Type.ROLL_NUM, 1);

    base.GetComponentInParent<UIMaster>().freezeObject(true);

    base.GetComponentInParent<UIMaster>().blockUI(true, new Color(0f, 0f, 0f, 0.3f));

    this.LotteryCheck();

    this.ResultButton.GetComponent<RollResultButton>().CngImage((int)this.result);

    this.ResultButton.GetComponent<RollResultButton>().CngResultText(Define.PrizeBallName[this.result] + "がでました");

    this.LotteryWheelPanel.GetComponent<LotteryWheelPanel>().OpenPanel(this.result);

    SuperGameMaster.SetTmpRaffleResult((int)this.result);

    SuperGameMaster.SaveData();

    SuperGameMaster.audioMgr.PlaySE(Define.SEDict["SE_Raffle"]);

    this.BackFunc();

}


从代码来看就是刚刚抽奖结果提示的地方,来分析下这个结果是怎么生成的,可以看到 Define.PrizeBallName[this.result] 这个result决定了抽到的是什么颜色的球,那么看看这个result是在哪里生成的。在当前文件中看到了两处赋值:

public void LotteryCheck()

{

    int num = Random.Range(0, Define.PrizeBalls[Rank.RankMax]);

    this.result = Rank.White;

    int i = 0;

    int num2 = 0;

    while (i < 5)

    {

        num2 += Define.PrizeBalls[(Rank)i];

        if (num < num2)

        {

            this.result = (Rank)i;

            break;

        }

        i++;

    }

}

 

ublic void SetTmpResult()

{

    this.result = (Rank)SuperGameMaster.GetTmpRaffleResult();

    this.BackFunc();

}


下面是在UIMaster_raffle.UI_Start中调用的,判断之前的奖品有没有领取,LotteryCheck这个刚好是在显示结果之前调用的,猜测就是这里控制的不同颜色的球的出现概率,分析下这段代码:

public void LotteryCheck()

{

    int num = Random.Range(0, Define.PrizeBalls[Rank.RankMax]); //Rank.RankMax是5,Define.PrizeBalls[Rank.RankMax]是100,从0-100随机生成一个数

    this.result = Rank.White;  //默认都是白色的球

    int i = 0;

    int num2 = 0;

    while (i < 5)

    {

        num2 += Define.PrizeBalls[(Rank)i];  //根据PrizeBalls的值小于60是白色,大于等于60并且少于87是蓝色,大于等于87小于96是绿色,大于等于96小于99是红色,大于等于99是金色。

        if (num < num2)

        {

            this.result = (Rank)i;

            break;

        }

        i++;

    }

}


从上面代码分析PrizeBalls中就定义了不同颜色的球摇出概率:

public static readonly Dictionary<Rank, int> PrizeBalls = new Dictionary<Rank, int>

{

    {

        Rank.White,

        60

    },

    {

        Rank.Blue,

        27

    },

    {

        Rank.Green,

        9

    },

    {

        Rank.Red,

        3

    },

    {

        Rank.Gold,

        1

    },

    {

        Rank.RankMax,

        100

    }

};


所以直接修改这里的数字就控制摇出的概率了,比如不出白球,其它球的概率都一样:

public static readonly Dictionary<Rank, int> PrizeBalls = new Dictionary<Rank, int>

{

    {

        Rank.White,

        0   //不存在白球

    },

    {

        Rank.Blue,

        25

    },

    {

        Rank.Green,

        25

    },

    {

        Rank.Red,

        25

    },

    {

        Rank.Gold,

        25

    },

    {

        Rank.RankMax,

        100

    }

};



修改农场四叶草数


农场里面大部分都是三叶草、四叶草的概率是很小的,那么怎么修改这个呢?在界面上面好像不太好找关联,先试试搜索三叶草的英语clover,点击checkCloverCreate看起来就是控制生成的函数:

 

public void checkCloverCreate()

{

    this.cloverList = SuperGameMaster.GetCloverList();

    bool flag = false;

    if (this.cloverList.Count == 0) 

    {

        flag = true;

        //这句话翻译的意思就是:有了四叶草的初期化标志。四叶草生成

        //也就是flag为true的时候会生成四叶草

        Debug.Log("[CloverFarm] クローバーの初期化フラグが立ちました。四葉を生成します");

    }

    if (this.cloverList.Count < this.cloverMax)

    {

        Debug.Log(string.Concat(new object[]

        {

            "[CloverFarm] クローバーの数を調整します:",

            this.cloverList.Count,

            " > ",

            this.cloverMax

        }));

    }

    while (this.cloverList.Count < this.cloverMax)

    {

        CloverDataFormat cloverDataFormat = new CloverDataFormat();

        cloverDataFormat.lastHarvest = new DateTime(1970, 1, 1);

        cloverDataFormat.timeSpanSec = -this.cloverList.Count - 1;

        cloverDataFormat.newFlag = true;

        this.cloverList.Add(cloverDataFormat);

    }

    if (this.cloverList.Count > this.cloverMax)

    {

        Debug.Log(string.Concat(new object[]

        {

            "[CloverFarm] クローバーの数を調整します:",

            this.cloverList.Count,

            " > ",

            this.cloverMax

        }));

        this.cloverList.RemoveRange(this.cloverMax - 1, this.cloverList.Count - this.cloverMax);

    }

    List<GameObject> list = new List<GameObject>();

    for (int i = 0; i < this.cloverList.Count; i++)

    {

        if (!this.cloverList[i].newFlag && this.cloverList[i].timeSpanSec <= 0)

        {

            list.Add(this.LoadCloverObject(i, this.cloverList[i]));

        }

    }

    int num = 0;

    for (int j = 0; j < this.cloverList.Count; j++)

    {

        if (this.cloverList[j].newFlag)

        {

            //这里根据flag调用不同的函数,

            if (!flag)

            {

                //生成三叶草

                list.Add(this.NewCloverObject(j, this.cloverList[j], list));

            }

            else

            {

                //生成四叶草,不同的是第四个参数为true

                list.Add(this.NewCloverObject(j, this.cloverList[j], list, true));

                flag = false;

            }

            this.cloverList[j].x = list[list.Count - 1].transform.localPosition.x;

            this.cloverList[j].y = list[list.Count - 1].transform.localPosition.y;

            Clover component = list[list.Count - 1].GetComponent<Clover>();

            this.cloverList[j].element = component.element;

            this.cloverList[j].spriteNum = component.spriteNum;

            this.cloverList[j].point = component.point;

            this.cloverList[j].newFlag = false;

            num++;

        }

    }

    foreach (GameObject gameObject in list)

    {

        int num2 = this.cloverOrderInLayer;

        foreach (GameObject gameObject2 in list)

        {

            if (gameObject.transform.position.y < gameObject2.transform.position.y)

            {

                num2++;

            }

        }

        gameObject.GetComponent<SpriteRenderer>().sortingOrder = num2;

    }

    Debug.Log(string.Concat(new object[]

    {

        "[CloverFarm] クローバー生成完了:",

        list.Count,

        "\u3000/ (新規:",

        num,

        ")"

    }));

}


首先看this.NewCloverObject(j, this.cloverList[j], list)这个内部调用就是四个参数的,只不过第四个参数是false:

public GameObject NewCloverObject(int index, CloverDataFormat cloverData, List<GameObject> cloversObj)

{

    return this.NewCloverObject(index, cloverData, cloversObj, false);

}


也就是第四个参数为true就是四叶草,点进去看看:

public GameObject NewCloverObject(int index, CloverDataFormat cloverData, List<GameObject> cloversObj, bool fourLeafFlag)

{

    Vector2 size = base.GetComponent<BoxCollider2D>().size;

    PolygonCollider2D component = base.GetComponent<PolygonCollider2D>();

    Vector2 vector;

    vector..ctor(base.GetComponent<BoxCollider2D>().offset.x - size.x / 2f, base.GetComponent<BoxCollider2D>().offset.y - size.y / 2f);

    int num = 0;

    bool flag;

    Vector3 vector2;

    do

    {

        flag = false;

        vector2 = new Vector2(vector.x + Random.Range(0f, size.x), vector.y + Random.Range(0f, size.y));

        if (!component.OverlapPoint(vector2 + base.transform.position))

        {

            flag = true;

        }

        else

        {

            for (int i = 0; i < cloversObj.Count; i++)

            {

                Vector2 size2 = cloversObj[i].GetComponent<BoxCollider2D>().size;

                if (Mathf.Abs(vector2.x - cloversObj[i].transform.localPosition.x) < size2.x / 2f && Mathf.Abs(vector2.y - cloversObj[i].transform.localPosition.y) < size2.y / 4f)

                {

                    flag = true;

                }

            }

            num++;

            if (num >= 100)

            {

                break;

            }

        }

    }

    while (flag);

    GameObject gameObject = Object.Instantiate<GameObject>(this.basePrefab, Vector3.zero, Quaternion.identity);

    CloverDataFormat cloverDataFormat = new CloverDataFormat();

    cloverDataFormat.point = 1;

    cloverDataFormat.element = 0;

    //如果第四个参数是false,这里就是四叶草生成的概率,是fourLeaf_percent是1那这个概率就是1/100

    if (Random.Range(0f, 10000f) < this.fourLeaf_percent * 100f)

    {

        cloverDataFormat.element = 1;

    }

    //如果传了这个参数为true直接生成四叶草

    if (fourLeafFlag)

    {

        cloverDataFormat.element = 1;

    }

    int element = cloverDataFormat.element;

    if (element != 0)

    {

        if (element == 1)

        {

            cloverDataFormat.spriteNum = Random.Range(0, this.fourCloverSprite.Length);

            gameObject.GetComponent<SpriteRenderer>().sprite = this.fourCloverSprite[cloverDataFormat.spriteNum];

        }

    }

    else

    {

        cloverDataFormat.spriteNum = Random.Range(0, this.cloverSprite.Length);

        gameObject.GetComponent<SpriteRenderer>().sprite = this.cloverSprite[cloverDataFormat.spriteNum];

    }

    gameObject.GetComponent<Clover>().SetCloverData(index, cloverDataFormat);

    gameObject.transform.parent = base.transform;

    gameObject.transform.localScale = Vector3.one;

    gameObject.transform.localPosition = vector2;

    return gameObject;

}


那么这里既可以修改四叶草生成的概率也可以修改 fourLeafFlag 这个为true,第一种方式和上面一样,看看第二种。右键fourLeafFlag编辑IL指令:

 

 

这里首先取fourLeafFlag判断然后跳转,所以修改为true即可。修改 ldarg.s fourLeafFlag为ldc.i4 1,修改后的代码如下:

if (true)

{

    cloverDataFormat.element = 1;

}



Android篇总结


Android的修改的部分就到这里了,其它的大家可以自己去尝试,总结下分析Unity3D游戏过程,当然这篇文章讲的是最简单的情况,还有使用保护将dll脚本加密的,就需要hook函数或者从内存查找dump,还有通过C#脚本调用lua脚本来实现的,逻辑在lua脚本里面,lua脚本又加密了的情况等等。


那么没保护的情况的话,一般可以从界面显示搜索来分析,然后根据一些特定单词去搜索查找关键代码部分。这篇主要是Android上面使用mono模式的情况,如果是使用IL2CPP的话比如iOS上面,C#脚本都转成了cpp文件c代码的形式的话分析起来就会麻烦很多,下面来看看iOS上面修改hook代码。


iOS


上面主要也是为下面做铺垫吧,因为在iOS现在都是IL2CPP模式,C#脚本已经被转成了C/C++代码。所以要单独分析iOS的话难度会大很多,如果从Android的C#脚本入手的话,因为iOS和Android脚本都是一样的话,可以从Android分析的函数名来对应iOS的c函数然后进行hook修改。


提取ipa


首先从越狱设备上面提取旅行青蛙的ipa包,使用frida-ios-dump一键提取即可。由于是日文名字,先通过./dump.py -l把名字列出来,然后复制名字或者通过bundle id去dump就可以了。


IL2CPP符号还原


由于使用IL2CPP选项编译unity游戏,会生成cpp的代码,直接使用IDA看是看不到函数和函数名的,而且游戏中使用的字符串都被保存在global-metadata.dat的资源文件里。


首先要通过提取global-metadata.dat文件里面的字符串对对应的c函数进行符号还原。具体也有现成的文章:还原使用IL2CPP编译的unity游戏的symbol,github上面也有线程的项目也做这件事情Il2CppDumper。


下载release的工具,运行Il2CppDumper.exe并依次选择il2cpp的可执行文件和global-metadata.dat文件,然后选择Auto(Plus)模式,将生成dump.cs文件和script.py脚本。使用IDA打开可执行文件然后使用script.py脚本即可还原符号。

 

Making method name...

Make method name done

Setting String...

Set string done

Making function...

Make function done, please wait for IDA to complete the analysis

Script finish !



根据函数hook代码


还原之后就可以根据之前分析到的函数名来hook对应的代码了,首先是三叶草的数目通过SuperGameMaster.CloverPointStock()获取的,在IDA搜索CloverPointStock如下:

 

 

接着就可以直接hook这个函数了,由于要inline hook目前是在越狱机器上面,后面会讲到非越狱机器hook的方案。使用MonkeyDev新建一个Logos Tweak项目,清空.xm的内容并写入如下内容:

#import <substrate.h>

#import <dlfcn.h>

#import <mach-o/dyld.h>

 

int (*old_clover_point_stock)(void);

 

int new_clover_point_stock(void)

{

    return 9999;

}

 

%ctor

{

    @autoreleasepool

    {

        unsigned long clover_point_stock = _dyld_get_image_vmaddr_slide(0) + 0x100093A2C;

        MSHookFunction((void *)clover_point_stock, (void *)&new_clover_point_stock, (void **)&old_clover_point_stock);

 

 

    }

}


然后在build settings里面设置端口和设备密码然后command + b安装就能看到效果了,其它函数的hook也是一样的:

#import <substrate.h>

#import <dlfcn.h>

#import <mach-o/dyld.h>

 

int (*old_clover_point_stock)(void);

 

int new_clover_point_stock(void)

{

    return 9999;

}

 

int (*old_ticket_stock)(void);

 

int new_ticket_stock(void)

{

    return 9999;

}

 

void (*old_lotterycheck)(uint64_t obj);

 

void new_lotterycheck(uint64_t obj)

{

    *(int*)(obj + 80) = rand() % 4 + 1;

}

 

uint64_t (*old_new_clover_object)(uint64_t obj, int index, uint64_t cloverData, uint64_t cloversObj, int fourLeafFlag);

 

uint64_t new_new_clover_object(uint64_t obj, int index, uint64_t cloverData, uint64_t cloversObj, int fourLeafFlag)

{

    return old_new_clover_object(obj,index,cloverData,cloversObj,1);

}

 

%ctor

{

    @autoreleasepool

    {

        unsigned long clover_point_stock = _dyld_get_image_vmaddr_slide(0) + 0x100093A2C;

        MSHookFunction((void *)clover_point_stock, (void *)&new_clover_point_stock, (void **)&old_clover_point_stock);

 

        unsigned long ticket_stock = _dyld_get_image_vmaddr_slide(0) + 0x100093AA4;

        MSHookFunction((void *)ticket_stock, (void *)&new_ticket_stock, (void **)&old_ticket_stock);

 

        unsigned long lotterycheck = _dyld_get_image_vmaddr_slide(0) + 0x100086CF4;

        MSHookFunction((void *)lotterycheck, (void *)&new_lotterycheck, (void **)&old_lotterycheck);

 

        unsigned long new_clover_object = _dyld_get_image_vmaddr_slide(0) + 0x100037100;

        MSHookFunction((void *)new_clover_object, (void *)&new_new_clover_object, (void **)&old_new_clover_object);

    }

}


这里有一个函数RaffelPanel$$LotteryCheck要修改里面的result的值,就要根据汇编或者伪代码来看result的赋值是在什么位置了,该函数通过F5获得的伪代码如下:

__int64 __fastcall RaffelPanel__LotteryCheck(__int64 a1)

{

  __int64 v1; // x19

  __int64 v2; // x0

  __int64 v3; // x0

  int v4; // w20

  int v5; // w23

  signed int v6; // w24

  __int64 v7; // x0

  __int64 result; // x0

 

  v1 = a1;

  if ( !(byte_10137EDBB & 1) )

  {

    sub_100DEAD34(6810LL);

    byte_10137EDBB = 1;

  }

  v2 = qword_101439198;

  if ( *(_BYTE *)(qword_101439198 + 266) & 1 && !*(_DWORD *)(qword_101439198 + 188) )

  {

    sub_100DFF71C();

    v2 = qword_101439198;

  }

  if ( !*(_QWORD *)(*(_QWORD *)(v2 + 160) + 192LL) )

LABEL_17:

    sub_100DE28B4();

  v3 = sub_1000FB954();

  v4 = Random__Range_71094(0LL, 0LL, v3, 0LL);

  v5 = 0;

  *(_DWORD *)(v1 + 80) = 0;   this.result = Rank.White;  //默认都是白色的球

  v6 = -1;

  while ( 1 )

  {

    v7 = qword_101439198;

    if ( *(_BYTE *)(qword_101439198 + 266) & 1 )

    {

      if ( !*(_DWORD *)(qword_101439198 + 188) )

      {

        sub_100DFF71C();

        v7 = qword_101439198;

      }

    }

    if ( !*(_QWORD *)(*(_QWORD *)(v7 + 160) + 192LL) )

      goto LABEL_17;

    result = sub_1000FB954();

    v5 += result;

    if ( v4 < v5 )  //if (num < num2)

      break;   

    if ( ++v6 >= 4 )

      return result;

  }

  *(_DWORD *)(v1 + 80) = v6 + 1;  //this.result = (Rank)i;

  return result;

}


这里的*(_DWORD *)(v1 + 80)的位置其实就是this.result所以直接修改80偏移位置的值就可以了。



iOS总结


总结来说的话,要分析iOS里面转换后的脚本C代码还是不容易的,如果能够根据Android C#脚本分析的结果然后对iOS的符号进行恢复一下的话,就可以直接根据Android分析到的函数直接来Hook iOS对应的函数来修改参数或者值了。不过这里还是在越狱设备上面进行的hook,然后可能还会写个非越狱设备同样进行静态的hook操作。





本文由看雪翻译小组 AloneMonkey 原创

转载请注明来自看雪社区


热门阅读


点击阅读原文/read,

更多干货等着你~



您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存