很多朋友不知道游戏外挂是什么,下面是百度百科的内容
百度百科:
外挂(wài guà),又叫开挂、开外挂、辅助、修改器,一般指通过修改游戏数据而为玩家谋取利益的作弊程序或软件,即利用电脑技术针对一个或多个软件进行非原设操作,篡改游戏原本正常的设定和规则,大幅增强游戏角色的技能和超越常规的能力,从而达到轻松获取胜利、奖励和快感的好处,通过改变软件的部分程序制作而成的作弊程序。
简单地说外挂分为两种(个人认为的)
- 自动挂机
- 变态功能
前言
小的时候,应该是我小学四五年级的时候,当时洛克王国刚出来,我就玩了,但是等级太难升了,别人升级都快,就我升级慢,后来问别人为什么可以升级这么快,到此我就知道了外挂的存在,可以自动升级。
这篇文章会讲解笔者对外挂的一些理解,以及一些主流外挂的实现方法。
按键挂
这种外挂我又分为三种:
- 单纯的按键,这种比较直接,但是貌似应该是很弱智(笔者从来没写过)
- 通过spy++获取窗口的坐标,通过发送鼠标消息(以前那种批量种植物的游戏我做过,但是这种的慢慢的也就不回去写了)
- 通过OCR技术识别图片的模,确定需要按键的位置,以前写过类似的外挂,但是已经找不到了,比较典型的框架就是:大漠,当然这种的还是可以识别验证码,以前做实现验证码识别的时候就会使用字模对比。
上面的这种按键挂主要用于挂机或者是作为其他外挂的一种功能的补充,因为离现在的技术已经有一定的距离了,所以也没有找到类似的实例了。
内存挂
这种外挂主要是通过writeprocessmemory函数进行内存的写入,通过偏移找基地址。
代表工具:
- CE
- VE
- Wker_Exedebug
因为这个也是其他外挂的一部分
- 修改内存:
单纯的修改内存的值 - path代码段
通过重写关键的代码实现游戏逻辑的变化。
挂机挂
这种的挂分为很多种类型
- 第一种就是我们上面说的按键外挂
- 第二种是通过汇编寻找call
这个类型的外挂是在15年的时候非常的流行,当时很喜欢玩这类的游戏,江湖类型的游戏,我有的时候也是会去做这类的外挂,下面是我当时做的一个小工具:
这种外挂已经没有什么太大的市场了,所以在这里不详细讲解
- 协议挂,这类的外挂相对简单,但是麻烦之处就是在分析协议,就和我们写爬虫类似,下面是我以前写的洛克王国外挂:
这种辅助其实实际上就是一种hook劫持数据包然后分析数据包的内容进行一些小手脚,或者发送指定的数据包代替人工对宠物进行训练。
因为这种游戏现在已经没有市场了,所以在这里不详细讲解
- 纯网页外挂:和之前的那个协议还是有一定的区别,纯http协议的外挂,其实无非就是封包的分析
- 对封包的分析
- 对js的加密破解
- 对cookie或者session的存储
- 代理的合理运用
- 验证码的破解
FPS挂
这种外挂主要的两个功能是两个:
- 透视
- 自瞄
类似于穿墙,飞天,无后座之类的,其实就是path代码段。
单单对于透视大体分为两种实现的方法:
- 通过内存读取敌法位置
- 通过三角函数算法计算
- 通过矩阵进行计算
- 通过hook D3D函数,清除指定的深度缓存
第一种主要实现的方框透视,并且附带着可以吧自瞄写出来,但是这种我感觉一种不是很关键了,这里我只给大家看看。
这种不是我们关注的重点
这种透视是比较关键的,我会将的详细一些。
之前做FPS的透视自瞄,都是通过三维坐标进行三角函数计算实现的方框自瞄,但是这种确实是有一定的占用内存的嫌疑,所以使用D3DHook是相对而言比较轻松的一种写法,而且也是比较固定的一个框架。
简单的D3D知识
// 从索引缓存区绘制图元,参数 1 为图元格式,参数 4 为顶点数,参数 6 为三角形数
m_pDevice->DrawIndexedPrimitive( D3DPT_TRIANGLELIST, 0, 0, 4, 0, 4 );
这句代码就是用来绘制我们的模型的,是位于:
m_pDevice->BeginScene();
/**/
m_pDevice->EndScene();
之间的一句代码,如果将其注释掉,那么将不会再绘制模型了,这也是我们实现Hook的一个关键点,因为我们在绘制完毕模型之后,我们就可以调用类中的一个方法:m_pDevice->SetRenderState( D3DRS_ZENABLE, D3DZB_FALSE );
这句代码如果不使用,默认为FALSE,但是开发人员为了将我们的模型隐藏在建筑物的后面,他会将我们的这个代码设置为TRUE,以此进行任务的遮挡,其实也就是我们的一个前面的物体遮挡后面物体的一个效果。
所以我们的基本思路也就定下来了,也就是我们Hook了DrawIndexedPrimitive
之后,然后将指定的模型SetRenderState
为FALSE就可以了。
实现
因为我们要Hook的DrawIndexedPrimitive
方法是一个成员方法,所以我们需要使用基址加偏移的方法进行一个Hook,其实C++的一个类成员方法的调用,第一个参数传递的就是成员本身,所以说,也就是调用了一个全局方法,然后将对象自身传递过去就实现了所谓的类方法调用,所以我们是要找到这个类方法的一个地址,但是这个地址比较特殊,我们是要使用基址加偏移的方法进行定位。
首先我们自己写一个这个方法,然后找到对应的地址:
00AA19A6 8B 46 78 mov eax,dword ptr [esi+78h]
00AA19A9 8B 08 mov ecx,dword ptr [eax]
00AA19AB 8B 91 48 01 00 00 mov edx,dword ptr [ecx+148h]
00AA19B1 6A 04 push 4
00AA19B3 6A 00 push 0
00AA19B5 6A 04 push 4
00AA19B7 6A 00 push 0
00AA19B9 6A 00 push 0
00AA19BB 6A 04 push 4
00AA19BD 50 push eax
00AA19BE FF D2 call edx
可以看到他call的函数并不是一个固定的地址,而是一个寄存器,寄存器的地址,其实也可以一步步推出来,比较关键的就是那个ESI,他的值是:0x012ffd38,但是我没校验是不是基地址,一般可以我感觉,但是这样有点麻烦,比较简单的方法就是直接进入edx那个地址,然后计算相对于DLL的一个偏移量,计算的时候用模块基地址加上偏移量就可以了。
然后我们跟进去看一下:
547348C0 8B FF mov edi,edi
547348C2 55 push ebp
547348C3 8B EC mov ebp,esp
模块是:d3d9.DLL,一计算发现RVA为:0x548c0,前五个字节是正好的,所以我们也就没啥麻烦的了,直接Hook就好了。
这里其他的一些代码我就不列出来了,因为不关键,我就写关键的:
首先我们需要一个计算地址的函数:
ULONG_PTR GetDrawIndexedPrimitiveAddress()
{
HANDLE h = GetModuleHandle("d3d9.dll");
if(h==INVALID_HANDLE_VALUE)
return NULL;
return (ULONG_PTR)h+0x548c0;
}
这样子就能找到函数的地址了,因为我们的DLL地址会变,所以每次都要找的。
然后当我们的DLL被注入的时候我们就去HOOKDrawIndexedPrimitive
这个函数:
bool HookDrawIndexedPrimitive()
{
ULONG_PTR address = GetDrawIndexedPrimitiveAddress();
jmpto =address+5;
DWORD oldPro;
if(VirtualProtect((LPVOID)address,5,PAGE_EXECUTE_READWRITE,&oldPro))
{
DWORD value = (DWORD)MyDrawIndexedPrimitive-address-5;
_asm
{
mov eax,address
mov byte ptr[eax],0xe9
add eax,1
mov ebx,value
mov dword ptr[eax],ebx
}
VirtualProtect((LPVOID)address,5,oldPro,&oldPro);
}
return true;
}
获取到函数地址之后,我们就要写HOOK,MyDrawIndexedPrimitive
这个使我们要跳到的地址,我们这里使用的HOOK方法我是看别人这么写的,大同小异,也就是第一个e9为jmp,然后再将我们的地址放进去就好了,简单计算一下得到value就可以了。
//这个WINAPI加不加代表返回的时候是retn还是retn 1c
//被这个关键字修饰的函数,其参数都是从右向左通过堆栈传递的(__fastcall 的前面部分由ecx,edx传), 函数调用在返回前要由被调用者清理堆栈(这句话是关键)。自己在退出时清空堆栈,所以这里要加否则堆栈不平衡
HRESULT WINAPI MyDrawIndexedPrimitive(LPDIRECT3DDEVICE9 pDevice,D3DPRIMITIVETYPE dtype,INT BaseVertexIndex,UINT MinVertexIndex,UINT NumVertices,UINT startIndex,UINT primCount)
{
IDirect3DVertexBuffer9* pStreamData = NULL;
UINT iOffsetInBytes,iStride;
if (pDevice->GetStreamSource(0,&pStreamData,&iOffsetInBytes,&iStride)==D3D_OK)
{
pStreamData->Release();
}
if (iStride==16)//深度缓存的值
{
pDevice->SetRenderState(D3DRS_ZENABLE,FALSE);
}
return OriDrawIndexedPrimitive(pDevice,dtype,BaseVertexIndex,MinVertexIndex,NumVertices,startIndex,primCount);
/*
_asm
{
mov esp,ebp
pop ebp
//原代码
mov edi,edi
push ebp
mov ebp,esp
mov eax,jmpto
jmp eax
}*/
//_asm
//{
// mov esp,ebp
// pop ebp
// retn 0x1c
//}
}
这里可以看到,原函数其实是6个参数,但是我们要七个参数,其实也就是我们要先接受自身,然后我们在最后的时候要跳回去:
DWORD jmpto = 0;
__declspec(naked) HRESULT WINAPI OriDrawIndexedPrimitive(LPDIRECT3DDEVICE9 pDevice,D3DPRIMITIVETYPE dtype,INT BaseVertexIndex,UINT MinVertexIndex,UINT NumVertices,UINT startIndex,UINT primCount)
{
_asm
{
mov edi,edi
push ebp
mov ebp,esp
mov eax,jmpto
jmp eax
}
}
一个空函数,jmpto是在我们之前定义的,这里需要注意的是我们需要使用WINAPI表示我们这个函数是stdcall的调用约定,这里我踩坑了,否则我们在函数执行结束之后是不能进行自动清理堆栈的。
这里我们就是一个基本的D3D操作,意思就是说iStride深度缓存的值未16的时候我们就让他透视,其实这个16是我们需要调试寻找的,可以通过很多手段,例如共享内存操作之类的,也可以使用设置新的lang来实现,反正很多都是可以找到的,但是我们找到16之后并不是万事大吉,因为进入游戏发现地图也没有了,所以我们要判断NumVertices的值,这个是模型的顶点数,也是需要寻找的,但是却不是最好的方法,最好的方法是我们通过附加游戏找到调用DrawIndexedPrimitive
这个函数的外层call找到有没有一个标识来代表是否可以表示人,我们再通过hook之类的判断来告诉我们是不是需要清除深度缓存。
Android 3D 外挂
通过上面的FPS外挂我们基本了解了一下,但是Android的是通过实现的,这里我详细说一下,准确的说PC端是D3D,而Android是U3D。
首先我们判断这个游戏是否是U3D可以根据是否在lib\armxxx目录下存在:libmain.so
,libmono.so
,libunity.so
。如果存在的话呢就八九不离十了,这个时候我们就可以来分析相关的DLL,如果有加密DLL,那么我们就在相关函数位置dump我们的DLL,然后替换成官网的mono,思路很多,网络上也是很多。
Assembly-CSharp.dll
加解密就是对这个DLL进行操作。如果没有加密的话呢,那么我们就可以直接使用Reflector进行反编译修改,下面就尝试修改实现无敌:
- 首先我们将我们的dll拖进去,然后点击搜索,搜索可能存在的字符,例如Dead,这个就是死亡,尝试寻找
这里需要注意我们点击的是第二个,因为第一个是搜索类,第二个才是搜索成员,搜索到一个函数之后,我们双机他,如果出现这个样子的一个对话框:
就是让你重新加载一下这个DLL,我们重新选择然后加载就好了 - 分析发现:
public void Dead()
{
GameMangerInit.freeGifts(GameManager.getInstance().giftUses);
GameManager.getInstance().giftUses = new Dictionary<string, GiftUse>();
Object.Destroy(this.skillAnim);
Object.Instantiate(Resources.Load("Prefabs/Effect/explosion"), base.get_transform().get_position(), Quaternion.get_identity());
base.get_gameObject().get_transform().set_position(new Vector3(0f, -12f, 0f));
this.isDead = true;
this.skillAnim = null;
this.isSkill = false;
this.riseTime = 0f;
showRecharge = false;
}
发现this.isDead = true;
这个语句就很类似与我们的玩家死亡,而且将其改为了true,那么我们只要将其改为false那么我们应该就会不死。
3. Relfexil插件
使用这个插件我们可以我们可以修改,他在下方会显示IL代码
然后我们找到相对应的位置:
可以看到这个ldc.i4.1是false,那么我们改为ldc.i4.0将会一直是true,我们右键点击Edit...,然后修改数据:
然后点击Update,之后我们选中我们添加的这个DLL,我们右键Relfexil插件,选项里有个Save-as我们另存为就可以了
- 替换DLL
最后我们替换我们的DLL,记住名称不要搞混忘记修改
然后有的时候需要重新签名也不要忘记,最终运行发现可以不死。
其实对于框架的Android游戏还有LUA游戏,这种游戏相对于反编译有可能失败,因为版本比较不同,这里不详细给大家讲解。
Android动态修改内存
这个比较有意思,比Windows有意思的多多了,我会讲的比较详细,大家往下看。
这个主要分为java层和so层的修改,java层基于反射,而so层的分类又分为两种,一种是不注入的修改,修改proc目录下的内容,而注入式就是我们的内存修改,也是比较常用。
so层修改
我们如果不修改代码段,仅仅是修改data段之类的是不需要修改属性页权限的。
实例代码:
static int total = 0;
JNIEXPORT jint JNICALL Java_com_example_testjni_NativeClass_settotal
(JNIEnv *, jobject)
{
total++;
return total;
}
就是一个简单的数据累加,我们的目的就是可以随意修改静态变量。
首先我们需要先找到这个变量的相对偏移,首先我们用IDA搜索这个导出函数:
这个带符号的加1其实就是我们的静态变量加一,所以这个静态变量的地址放在了我们的R0寄存器,而R0的值就是来自于R3寄存器指向的地址的值,所以R3是我们需要分析的,R3的地址是之前的R3加上PC,IDA给我们计算出来其实实际上就是加上4004,而这个4004是如何计算出来的呢?
我们动态分析一下,动态调试转到之后看到这个代码:
libTestJNI.so:4E257F88 Java_com_example_testjni_NativeClass_settotal
libTestJNI.so:4E257F88 LDR R3, =(dword_4E25B004 - 0x4E257F8E)
libTestJNI.so:4E257F8A ADD R3, PC ; dword_4E25B004
libTestJNI.so:4E257F8C LDR R0, [R3]
libTestJNI.so:4E257F8E ADDS R0, #1
libTestJNI.so:4E257F90 STR R0, [R3]
libTestJNI.so:4E257F92 BX LR
libTestJNI.so:4E257F92 ; End of function Java_com_example_testjni_NativeClass_settotal
libTestJNI.so:4E257F92
R3的值一开始是4E25B004 - 0x4E257F8E
得到的,经计算是:0x3706
确实是0x3706,我们增加的这个PC其实是IDA的PC往下指向的2两条指令,下面两条指令都是两个字节,所以是4个字节(thumb指令),而当前的指令(执行到ADD的时候)PC是我们当前查看的这个模块加上F8A:
所以我们应该使用基地址+0xF8A+0x4+0x3076=0x4004
所以我们静态变量的地址应该是这个so模块加上0x4004得到的地址。
最后我们可以看下R0的值:
确实使我们的变量。
分析到这里就可以了,我们现在需要解决的就是我们如何修改这个内存单元的值,还是用注入实现吧,方便快捷。
首先我们先要有这样一个函数:
bool GetModuleBase(long long * ulModBase,pid_t pid,const char * pszModName){
bool bRet = false;
FILE * fp = NULL;
char szMapFilePath[32]={0};
char szMapFileLine[1024]={0};
if(pszModName == NULL)
{
return bRet;
}
if(pid < 0)
{
sprintf(szMapFilePath,"/proc/self/maps");
}else{
sprintf(szMapFilePath,"/proc/%d/maps",pid);
}
fp = fopen(szMapFilePath,"r");
if(fp != NULL)
{
while(fgets(szMapFileLine,sizeof(szMapFileLine),fp)!=NULL)
{
if(strstr(szMapFileLine,pszModName))
{
char * pszModAddrStart = strtok(szMapFileLine,"-");
if(pszModAddrStart)
{
*ulModBase = strtoul(pszModAddrStart,NULL,16);
if(*ulModBase == 0x8000)
*ulModBase =0;
bRet = true;
break;
}
}
}
fclose(fp);
}
return bRet;
}
这个函数是用来获取我们模块基地址的:
- 第一个参数由于我们用的是c语言,所以不能用引用,只能指针传入,并且,c语言要用bool这个命名,我们需要引入:
#include <stdbool.h>
。 - 第二个参数是我们要查询的进程PID
- 第三个参数是我们要查询的模块名称
long long address;
GetModuleBase(&address,getpid(),"libTestJNI.so");
这个样子address这个变量里面存储的就是我们的模块基地址,既然得到了变量基地址,那么我们就可以用内存拷贝去修改我们的值:
int tmp=100;
memcpy((void*)(address+0x4004),(void*)&tmp,4);
这个样子就可以了,还是之前我们用到的ADBI框架。
./hijack -d -p 1935 -l /data/local/tmp/libexample.so
1935是我们进程的PID。
java层就是将这个变量弹出吐司。
绕过保护
这里我简单的分为Windows和Android两种,先说Android。
主要是用来防止IDA进行附加的,主要的方法思路就是,判断自身是否有父进程,判断是否端口被监听,然后通过调用so文件中的线程进行监视,这个线程开启一般JNI_OnLoad中进行开启的。
过反调试的话呢,就要把相关的函数进行NOP掉,直接将这几个字节改为00就可以了。
示例
首先反编译一下:
发现自带不可以调试,我们给他增加上:android:debuggable="true"
在application的节点中,然后进行编译,签名,安装。
然后打开IDA进行附加:
- 常规的运行服务器,转发端口
- 然后使用调试模式进行运行:
adb shell am start -D -n com.yaotong.crackme/.MainActivity
- 运行之后,使用IDA进行附加,附加的时候正常操作,但是进去之后我们需要选择一下:
在调试器的调试器选项中设置:
我们勾选上在载入动态库的时候断下。 - 然后点击运行,之后就会不动,这个时候我们用jdb绑定端口(ddms需要打开的情况下使用8700端口),使用命令:
jdb -connect com.sun.jdi.SocketAttach:hostname=127.0.0.1,port=8700
然后程序就会断下,因为在加载so文件。 - 然后我们一次运行就查看是否是我们的so被载入,我们的so文件名是:libcrackme.so
- 当被载入的时候,我们就选择在JNI_OnLoad中进行下段。
- 然后我们慢慢运行,直到我们运行至BXL R7的时候,IDA自己就掉了,所以我们可以推断,这个BLX跳转是反调试我们的IDA,这个时候我们就可以使用十六进制修改器修改我们的程序,将这个BLX R7改为00000000
- 转到十六进制视图,找到这个语句:37 FF 2F E1这四个字节我们就可以在十六进制修改器中修改为00 00 00 00.
- 然后重新编译。
这个时候我们重新安装运行,我们还是用调试模式运行:adb shell am start -D -n com.yaotong.crackme/.MainActivity
,应该也不需要了,因为反调试让我们给nop掉了,我们这个时候运行,发现程序正常被IDA附加了。
java分析
反编译的java伪代码如下:
public class MainActivity
extends Activity
{
public Button btn_submit;
public EditText inputCode;
static
{
System.loadLibrary("crackme");
}
protected void onCreate(Bundle paramBundle)
{
super.onCreate(paramBundle);
setContentView(2130903040);
getWindow().setBackgroundDrawableResource(2130837504);
this.inputCode = ((EditText)findViewById(2131099648));
this.btn_submit = ((Button)findViewById(2131099649));
this.btn_submit.setOnClickListener(new View.OnClickListener()
{
public void onClick(View paramAnonymousView)
{
paramAnonymousView = MainActivity.this.inputCode.getText().toString();
if (MainActivity.this.securityCheck(paramAnonymousView))
{
paramAnonymousView = new Intent(MainActivity.this, ResultActivity.class);
MainActivity.this.startActivity(paramAnonymousView);
return;
}
Toast.makeText(MainActivity.this.getApplicationContext(), "验证码校验失败", 0).show();
}
});
}
public native boolean securityCheck(String paramString);
}
载入时候的,发现按钮被点击之后,调用了securityCheck这个方法,这是个本地函数,我们在IDA中进行寻找:
看一下IDA给我们的伪代码:
signed int __fastcall Java_com_yaotong_crackme_MainActivity_securityCheck(int a1, int a2, int a3)
{
int v3; // r5
int v4; // r4
unsigned __int8 *v5; // r0
char *v6; // r2
int v7; // r3
signed int v8; // r1
v3 = a1;
v4 = a3;
if ( !byte_B3DF5359 )
{
((void (__fastcall *)(void *, signed int, void *, void *, signed int, signed int))unk_B3DF1494)(
&unk_B3DF5304,
8,
&unk_B3DF346B,
&unk_B3DF3468,
2,
7);
byte_B3DF5359 = 1;
}
if ( !byte_B3DF535A )
{
((void (__fastcall *)(void *, signed int, void *, void *, signed int, signed int))unk_B3DF14F4)(
&unk_B3DF536C,
25,
&unk_B3DF3530,
&unk_B3DF3474,
3,
117);
byte_B3DF535A = 1;
}
((void (__fastcall *)(signed int, void *, void *))unk_B3DF00D4)(4, &unk_B3DF5304, &unk_B3DF536C);
v5 = (unsigned __int8 *)(*(int (__fastcall **)(int, int, _DWORD))(*(_DWORD *)v3 + 676))(v3, v4, 0);
v6 = off_B3DF528C;
while ( 1 )
{
v7 = (unsigned __int8)*v6;
if ( v7 != *v5 )
break;
++v6;
++v5;
v8 = 1;
if ( !v7 )
return v8;
}
return 0;
}
很直观,和流程图中的差不多,前面几个是if语句,可能在操作一些加密之类的东西,不是很重要,但是我们发现最后返回的是根据V8和V0进行判断的,在java层我们也分析道我们要的就是返回值,看最后的循环语句,可以清楚的发现,其实就是就是一个类似于strcmp的一个函数,每一个字符进行比较,如果有一处不一样,就跳出循环,否则都一样的话呢,返回v8,这个是1,这下就好看了,我们有两种方法,获取到v5的值,这个就是注册码,或者是将这个返回修改一下也是可以的。
-
获取注册码:
我们在这个循环判断之前下断:
发现其实就是每次取出一个字符,然后进行判断,这个R2中的或者R0中的就是我们的注册码,动态调试一下:
这个不是我们输入的,我输入的是123,这个不一样,所以基本可以判定是注册码,发现真是! -
修改跳转
第一个BNE的跳转发现,如果满足,就回到下面的一个分支,而不满足就到另外一个分支,而下面的分支中又有一个BNE,这个BNE会往上进行跳转,这个是不是就是很容易理解了,所以这个就是我们while循环,并且进一步分析,发现我们第一个BNE的不进入循环的分支就是返回,也就是返回假,而进入循环一直循环的结束分支也就是左边的那个,应该就是返回真的,所以我们在so层的话呢,我们最好的办法就是直接将这两个BNE给NOP掉,这样子的话呢我们就可以顺序执行,最终只循环一次就可以实现返回真!
我们将第一个BNE:05 00 00 1A修改为00 00 00 00
我们将第一个BNE:F6 FF FF 1A修改为00 00 00 00
尝试修改编译之后发现,确实是实现了!
并且其实在java层的修改其实也是可以实现我们的一个程序破解的!
Windows过驱动
这个我真的写的有些累了,下次随着我发关于破解PC、安卓的文章一起发的,其实这次我还准备发许多hook框架出来,还有需要多好玩的东西,但是文章现在实在是太长了,而且我也太累了,所以只能停下来了。
后话
如果你对Windows和Android逆向以及渗透和编程十分感兴趣,十分欢迎您可以来关注我的blog:https://wker666.github.io
好累...
文章我已经尽量避免出现有关利益的内容!内容均是我对游戏外挂的理解,都是比较浅薄的内容
在这里求一个资源,龙珠英雄的,网上的都卡的看不了,翻墙的外语看起来不是很爽,我又比较喜欢看,所以请问各位大佬有没有资源。