[摘要]   1.轻松找出脱壳后的OEP。
........2.ESP定律和pushad、popad成对理论都是不对的。
........3.RVA困扰将不复存在,菜鸟也可轻易玩脱壳,只需了解简单地加减400000的原则。
[预备知识] 只须对PE文件头结构有初步的了解。
  
  我贴了几篇烂文就有点沾沾自喜起来,闲着没事又打起了“壳”的歪主意。我想找一个短小的“软壳蛋”来小试一下鸡刀,没想挑选到了一个“铁核桃”,差点没把牙齿啃脱。壮着胆子闯下去,咳~!终于出来了,还悟出不少道理来。
  秀才耍棍棒,还得要点基本功。“壳”这个东西对菜鸟有些神秘化,主要原因是缺乏对PE文件结构的了解,更对RVA的转换头痛。脱壳之前先将段钢的“加密与解密”或罗云彬的“汇编程序设计”中关于PE文件结构部分读三遍就可以应负了!下面用脱FantaMorph.exe 的注册机Keygen.exe(老外写的)的壳为例,谈点脱壳思路和方法。(Keygen.exe见附件,请高手验证一个该壳是软还是硬?)
  一、奇怪的PE头和脱壳的初试牛刀
  1.没见过这样的PE头:
  先用16进制编辑器打开keygen.exe文件,看看它的PE头,如下:

00400000  4D 5A 00 00 00 00 00 00 00 00 00 00 50 45 00 00  MZ..........PE..
00400010  4C 01 02 00 46 53 47 21 00 00 00 00 00 00 00 00  L  .FSG!........
00400020  E0 00 0F 01 0B 01 00 00 00 2C 00 00 00 50 01 00  ?   ...,...P .
00400030  00 00 00 00 54 01 00 00 00 10 00 00 0C 00 00 00  ....T ... ......
00400040  00 00 40 00 00 10 00 00 00 02 00 00 04 00 00 00  ..@.. ... .. ...
00400050  00 00 00 00 04 00 00 00 00 00 00 00 00 30 02 00  .... ........0 .
00400060  00 02 00 00 00 00 00 00 02 00 00 00 00 00 10 00  . ...... ..... .
00400070  00 10 00 00 00 00 10 00 00 10 00 00 00 00 00 00  . .... .. ......
00400080  10 00 00 00 00 00 00 00 00 00 00 00 98 23 02 00   ...........? .
00400090  84 00 00 00 00 C0 01 00 14 0D 00 00 00 00 00 00  ?...?. .......
004000A0  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
004000B0  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
004000C0  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
004000D0  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
004000E0  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
004000F0  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
00400100  00 00 00 00 00 00 00 00 00 00 00 00 00 B0 01 00  .............?.
00400110  00 10 00 00 00 00 00 00 00 00 00 00 00 00 00 00  . ..............
00400120  00 00 00 00 00 00 00 00 E0 00 00 C0 00 00 00 00  ........?.?...
00400130  00 00 00 00 00 70 00 00 00 C0 01 00 19 64 00 00  .....p...?. d..
00400140  00 02 00 00 00 00 00 00 00 00 00 00 00 00 00 00  . ..............
00400150  E0 00 00 C0 87 25 DC 23 42 00 61 94 55 A4 B6 80  ?.?%?B.a?ざ?
00400160  FF 13 73 F9 33 C9 FF 13 73 16 33 C0 FF 13 73 1F  ?s?? s 3? s
00400170  B6 80 41 B0 10 FF 13 12 C0 73 FA 75 3A AA EB E0  ?A?? 荔?:?
00400180  FF 53 08 02 F6 83 D9 01 75 0E FF 53 04 EB 24 AC  ?  ??uS ?
00400190  D1 E8 74 2D 13 C9 EB 18 91 48 C1 E0 08 AC FF 53  谚t- 呻 ?拎 ?S
004001A0  04 3B 43 F8 73 0A 80 FC 05 73 06 83 F8 7F 77 02   ;C?.?s ?w 
004001B0  41 41 95 8B C5 B6 00 56 8B F7 2B F0 F3 A4 5E EB  AA?哦.V?+痼ま
004001C0  9F 5E AD 97 AD 50 FF 53 10 95 8B 07 40 78 F3 75  ???? ?@x篚
004001D0  03 FF 63 0C 50 55 FF 53 14 AB EB EE 33 C9 41 FF   ?.PU? ??闪?
004001E0  13 13 C9 FF 13 72 F8 C3 02 D2 75 05 8A 16 46 12    ? r? 阴 ?F 
004001F0  D2 C3 4B 45 52 4E 45 4C 33 32 2E 64 6C 6C 00 00  颐KERNEL32.dll..
  
  这是什么PE文件头啊?PE标记怎么跑到第1行上去了?怎么一个区段块都没有?别急,按着标准结构一步步找下去。开始的DOS头标记“MZ”存在,在DOS的IMAGE_DOS_HEADER结构中,成员e_lfanew(名字不重要,只要知道“偏移”=3Ch就足够了)是指向PE头的。该成员在0+3Ch处,值是0C(双字,低位在前),显然PE二字标记该出现在0C位置。果然,但却跑到IMAGE_DOS_HEADER结构中去了。(这是我们以前不敢想的事,也给我们启发:只要MZ字符在,e_ifanew成员在,IMAGE_DOS_HEADER结构中的其它字节都可以任意修改。)
  (1)找到PE头后,往下走(要比照到PE头结构表查找),4C01是CPU类型,0200是块数目,2块?这是欺骗比尔.盖茨让他放行的,脱壳后块数目可以会增加很多。46 53 47 21是日期,E0 00是PE头结构大小,这个大小目前好象没人敢动?我想比尔.盖茨把它放在这里是可以改动的,只要相应地增加PE头的尺寸与这个数据相符是可以的?前面DOS文件头和PE文件头交织在一起不就是很好的例证吗?
  (2)其它不用多说。重要的是34h(对PE头的偏移=28h)位置的54 01 00 00是入口地址。60h位置的00 02 00 00是PE头+节表的尺寸,总长度才200h,程序入口怎么跑到节表里面来了?看来,你只要向比尔.盖茨递交几个确定文件属性和位置的必要数据后,其它懒得管你!
  (3)对PE头就先认识到到这里。以后还要回来和它打交道。其实你先可以连入口地址都不必急于去认识,OD一运行就自动停在入口了。
  
  2.何必这样劳神费力,找几个脱壳工具试试?
  这话有理,忙找来几个壳识别工具上阵,个个都摇头不认识!更不说脱壳了,这道也是,没准那天我也搞出个什么壳来,这些工具认得过来吗?特别是菜鸟们不要迷信那些工具,大多是为一些特例设计的,当人们不再使用众人皆知壳时,它就没用了。自己动手成长可能还会更快一些。Dump,Dump吧,就用OD自带的dump插件吧!运行后一试,OD反到问你:OEP定在那里?自动吧,结果令你哭笑不得,OEP居然跑到kernel32.dll中去了。原来它们判断OEP的方法大概是找jmp最远的地址?记住,凡事都有例外!
  
  3.脱壳的ESP定律、pushad,popad成对的定律有些不灵了:
  有些网友总结出脱壳的一些“定律”,如ESP定律、pushad,popad成对等定律。这些经验不能不说可贵,但一旦上升为“定律”,就不得不论证了。写软件就象写小说,谁知道作者怎样编造故事情节?谁说call后的[ebp+4]就一定是返回地址?程序轻易就可以将该值偷换了。不信就看看这个壳。
  该程序的解壳代码总长不过9Eh字节,从154~1F1。一开始就是popad:

00400154    8725 DC234200.......xchg dword ptr ds:[4223DC],esp
0040015A    61..................popad
0040015B    94..................xchg eax,esp
0040015C    55..................push ebp
0040015D    A4..................movs byte ptr es:[edi],byte ptr ds:[esi]
0040015E    B6 80...............mov dh,80
00400160    FF13................call dword ptr ds:[ebx]
00400162    73 F9...............jnb short 0040015D                  ; 0040015D
00400164    33C9................xor ecx,ecx
00400166    FF13................call dword ptr ds:[ebx]
  154是入口地址,15A就popad,从头到尾找不到pushad。没有pushad那来popad?这不仅仅是事实,而且软件运行得很正常。原来第1句154将esp指向了内存4223DC,第2句popad将内存4223DC前的8个双字弹到了寄存器中去了,第3句将esp还原!好绝啊!这里pushad,popad成对理论是没戏了。再看ESP定律,在我的电脑中,开始esp=13FFC4,若用HW 13FFC0设断,大概只能单步单步地走了,你看看接下来的一连串的call [ebx],个个都要入栈出栈,能连续运行吗?ESP定律也不灵了。
  
  二、壳的基本属性和功能
  我先声明,我只读过文中开始提到的那两本书,不敢说对壳有深入的了解!而且才开始学脱壳。何况壳是人写的,没准明天有人就在里面添加了什么新花样。
  有人说,壳在程序运行前先取得控制权,解壳后才将控制权交给应用程序。我认为过程大概是没错,但说法似乎不太准确。壳其实就是应用程序的一部分,windows将控制权交给应用程序不是在解壳后,而是在解壳前。准确地说,windows将文件读入内存后,首先查找IAT表(导入地址表),若有,就装载相关的API函数地址,再将与程序有关的dll和数据作一些初始化的工作,完了之后就查找入口地址,将eip指向入口后,就将控制权交给了程序。以后控制权的事是属于程序内部的权利之争,windows是不管的。我说这些,不是说我有什么知识,了解这些为后面找到OEP和脱壳后的IAT地址的修改带来极大的方便。
  那么些壳有什么功能呢?我体会有:(1)解压,(2)装载API地址,(3)跳出出口。第(2)(3)条就是我们攻破壳的重要思路。为什么说壳要装载API呢?因为同一个API函数在不同的电脑上的地址是不同的,壳不能先把地址填好再封装,只能现场装载API。请注意,现场!也就是说壳只有先解压,直到所有的API名称归位后(解压到指定位置)才能装载,当我们发现它在装载API地址时,壳离出口就近在咫尺了。另一个更大的好处是我们可以在内存中找到这些归位后的API,IAT表就在它附近。在壳装载IAT表前把它拷贝下来,当dump后,再将它拷贝到文件中去,IAT表就基本修改好了!
  写到这里,我突然想到了什么……。不如在壳开始装载时,就强迫它提前出壳,连IAT表都是现存的!(不管是壳装载还是windows装载,都必需IAT表,故任何壳一定要先解压出一个IAT表来才开始装载,我们抢先抓取这个IAT表会得到极大好处)

  三、寻找OEP的两种思路
  1.最经典的方法就是找jmp转跳的地址:
  前面提到壳必须跳出出口(不能返回),只能用jmp(未必),条件转跳是不行的(跳程太短),call上做点手脚是可行的,不知目前有没有?本程序的解壳代码中共有5个jmp,其中4个都是在本段内跳,只有一个是jmp [ebx+c],它肯定是出口无疑了。在这儿设断逮住OEP是没问题了。万一壳很长,在众多的jmp中找到出口也是有困难的。
  2.用硬件中断,拦截LoadLibrary或GetProcAddress:
  众多的壳将所用的API封装起来,但有两个不能封装,这就是LoadLibrary和GetProcAddress。这两个函数的地址必须在windows交控制权给程序前先由windows装载,如果你把它封装了,windows就不装载它。没有这两个函数,应用程序任何一个API函数的地址都是找不到的(任何壳都不例外),因为这是比尔.盖茨规定的。在壳中可以轻易地拦截到这两个函数,这是壳最大的软肋。(若你自己写一个类似的装载函数又当另议,可能马脚会更大)。马上实验,用OD打开Keygen,在弹出式菜单中找“搜索—全部模块中的命令”,找到GetProcAddress记下地址:4223F4,在OD命令拦中输入:HR 4223F4回车。运行后立即中断在:4001D6。(若在kernel32中,按ctrl+F9返回)

004001C2    AD..........lods dword ptr ds:[esi]
004001C3    97..........xchg eax,edi
004001C4    AD..........lods dword ptr ds:[esi]
004001C5    50..........push eax
004001C6    FF5310......call dword ptr ds:[ebx+10]
004001C9    95..........xchg eax,ebp
004001CA    8B07........mov eax,dword ptr ds:[edi]
004001CC    40..........inc eax
004001CD    78F3........js short 004001C2                   ; 004001C2
004001CF    7503........jnz short 004001D4                  ; 004001D4
004001D1    FF630C......jmp dword ptr ds:[ebx+C]
004001D4    50..........push eax
004001D5    55..........push ebp
004001D6    FF5314......call dword ptr ds:[ebx+14]          ; kernel32.GetProcAddress
004001D9    AB..........stos dword ptr es:[edi]
004001DA    EBEE........jmp short 004001CA                  ; 004001CA
  
  看到了吧,它正是GetProcAddress,这时要取消硬件中断(否则每次都要到kernel32中去转一回),从状态拦中发现dei指向404000,内存换到404000,稍住下走就发现了一连串的API函数名。在程序第一次通过4001D9前,拷贝下404000~4040AF地址中的值,这正是我们需要的IAT表中的值。(若按前面“突然想到”的方法提前出壳的,无须拷贝,但dump后还是要稍加修改。)
  注意到本段程序,单步执行时,程序始终不进入4001D1,原来进入的条件是eax=-1,原来eax是在404000~4040AF取值,当取到404078时,eax=-1,这时API函数也装载完了,程序就进入jmp [ebx+C],要跳出了,这时刻状态拦显示:4038EA。这不正是OEP么?

  四、抓取内存影像,修改IAT表
  由于有了OEP,在OD中可以dump了,将脱壳的文件抓取出来的内存影像取名dump.exe存盘。
  1.轻松解决IAT表中地址转换的RVA问题:
  内存地址和磁盘文件地址的RVA转换问题是新手的一个难于解决的困难,甚至一些老手有时也会犯糊涂,因为在不同的区段中RVA是不同的。但脱壳又回避不了这个问题,故很多新手望而却步。现在却可以轻松解决了,奥妙在那里?往下看:把前面提到的内存中404000~4040AF的那段数据拷贝如下:
  (若按前面“突然想到”的方法提前出壳的,也要了解修改方法。)

00404000  49 41 40 00  59 41 40 00  B7 42 40 00  CB 42 40 00  IA@.YA@.仿@.寺@.
00404010  FF FF FF 7F  35 42 40 00  A9 42 40 00  D7 41 40 00  ??5B@.┞@.琢@.
00404020  DF 41 40 00  E7 41 40 00  FD 41 40 00  05 42 40 00  吡@.缌@.?@. B@.
00404030  95 42 40 00  1B 42 40 00  25 42 40 00  13 42 40 00  ?@. B@.%B@. B@.
00404040  41 42 40 00  55 42 40 00  65 42 40 00  75 42 40 00  AB@.UB@.eB@.uB@.
00404050  83 42 40 00  FF FF FF FF  BF 41 40 00  AD 41 40 00  ?@.??苛@.?@.
00404060  9D 41 40 00  73 41 40 00  91 41 40 00  85 41 40 00  ?@.sA@.?@.?@.
00404070  FF FF FF 7F  00 00 00 00  FF FF FF FF  28 3B 40 00  ??....??(;@.
00404080  3C 3B 40 00  00 40 40 00  64 41 40 00  58 40 40 00  <;@..@@.dA@.X@@.
00404090  CA 41 40 00  14 40 40 00  F0 41 40 00  00 00 00 00  柿@. @@.鹆@.....
004040A0  00 00 00 00  00 00 00 00  00 00 00 00  E8 40 00 00  ............枥..
  
  注意表中的第一个双字:404149,查一查该地址是什么?40414A是字串:GetTickCount,它是kernel32.dll中的函数。为什么404149不直接指到函数名呢?原来调用GetProcAddress前有个inc eax,这就对了。但windows加载和自己调用GetProcAddress加载稍的区别,windows加载可以给被加载函数编号,所以每个函数名前都留有2字节的编号位。若让它为00 00,则编号为0。但这两字节却不能少,若让windows加载,404149指的位置也不对,只能使用404148给字串前留出两个空位!这样修改方就法明确了。
  再用16进制编辑器打开dump.exe,找到和上面位置相同的地方(再稍往下走就是连串的API函数名),把它也拷贝在这儿对比修改:
00004000  35 30 02 00 44 30 02 00   4E 30 02 00 61 30 02 00
00004010  00 00 00 00 00 00 00 00   00 00 00 00 00 00 00 00
00004020  00 00 00 00 00 00 00 00   00 00 00 00 00 00 00 00
……………………………………………………
  现在根本就完全舍去了RVA这个概念。你只须要把前表中的每个值(记住低位减1),去掉那个40,照原样填写在下表相应的位置中,并把7FFFFFFF和FFFFFFFF改为全0,IAT表就改选完成了。即:
00004000  48 41 00 00  58 51 00 00   B6 42 00 00  CA 42 00 00
00004010  00 00 00 00  34 42 00 00   A8 42 00 00  D6 41 00 00
00004020  DE 41 00 00  E6 41 00 00   FC 41 00 00  04 42 00 00
………………………………………………………
  注意,填写的这个值,与dump.exe文件中该表所处的地址一点关系都没有,比方说,如果该表地址不是00004000,而是00003200等什么的,填回的值也是上面这些吗?答案是肯定的!
  奇了!绝了?为什么会是这样呢?原来,你以为比尔.盖茨会为每一个应用程序去计算RVA吗?错了!现在不是比尔.盖茨迁就他的用户,而全世界都在迁就比尔.盖茨!这个RVA就是软件编译器为迁就微软而设计的,害苦了不少人。Windows根本不管你文件IAT表中是什么值,初始化时它只管在每个地址上加上400000(装载地址),其它一概不管!
  若你一定要弄清楚这个RVA是怎么回事,那么可假没dump.exe的IAT表地址在3200,程序要正常运行,则表中的值是不能变的。如下:
00003200  48 41 00 00  58 51 00 00   B6 42 00 00  CA 42 00 00
00003210  00 00 00 00  34 42 00 00   A8 42 00 00  D6 41 00 00
……………………………………………………
  4148指向了一个莫名其妙的地方,而3248才指向了一个API函数名(有两空位)。若将该位置写成3248,程序立即崩溃,必须将3248+RVA填入该地址,程序才能运行(RVA=F00)。你会说RVA不就是4148-3248吗?没错,但当该地址值是空白或别的什么值时,你能说出是多少吗?比尔.盖茨才不管这些,只管给你加400000,软件出品前你得将本应该指向API的3248换成一个莫名其妙的4148。这就是RVA转换,害人。(这个RVA我不想说了,也说不清楚,读者也不感兴趣了)
  由于老比的偷懒,也乐得我也拣个现存。这就是前面说的只要去掉那个40,低位再减1就将IAT表修改好了。

  五、最后的工作
  累了吧?还剩下一点工作,不过不太复杂。坚持下去吧!
  1.创建IID表(导入表):
  脱壳后的程序大多没有IID表,即使有也被写上了垃圾代码,只有重建。所谓IID表,就是指向几个*.dll函数名的表,windows是根据它来找到API的。依然用16进制编辑器打开抓取的dump到4000。我们发现414A开始就是API名,在它们中只须找出带*.dll的函数名,并记下它的地址。它们是:kenerl32.dll,user32.dll,msvcrt.dll。如00004164是kenerl32.dll。

00004140  00 00 00 00 00 00 00 00  00 00 47 65 74 54 69 63  ..........GetTic
00004150  6B 43 6F 75 6E 74 00 00  00 00 6C 73 74 72 6C 65  kCount....lstrle
00004160  6E 41 00 00 4B 45 52 4E  45 4C 33 32 2E 64 6C 6C  nA..KERNEL32.dll
…………………………………………………………
  简单地说,一个IID表(简化可用的)数据结构如下(它可以建在任何位置):
0000xx00  00000000  00000000  00000000  64410000  
0000xx10  00400000
  该数组前面那个4164就指向KERNEL32.dll(windows不给库函数留编号位),后面那个4000就指向前面我们修改过的IAT表中的一个地址。万一这个字串地址不在00004164,而是00003264,如下:
00003260  6E 41 00 00 4B 45 52 4E 45  4C 33 32 2E 64 6C 6C  nA..KERNEL32.dll
  那个IID表还是填64410000吗?只要装载后该字串地址在00404164,则答案是肯定的,而不管磁盘文件中这个字串的地址在那里!去掉老比给加上的那个40,就是文件需要的实际值。如此说来,这是很简单的事吗!世间万物懂了都简单。我代劳写出全部的IID表:在表的最后必须有5*8个0字节。
000040A0  00000000  00000000  00000000  64410000  
000040B0  00400000  00000000  00000000  00000000
000040C0  CA410000  58400000  00000000  00000000
000040D0  00000000  F0410000  14400000  
…………………………………………………………………………  
  只须注意每个IID表的最后那组数(第5组),它指向的是该.dll库中调用的第一个API的(地址),装载前其值指向它自己的函数名,装载后是该函数的地址。用4个0作为一个库结束的标记。至此,程序代码的所有改选都已完成,最后只需在PE头中登记即可。
  
  2.在PE头上登记你的IID表、IAT表位置和表的长度:
  记住了简单地减去400000的原则了吧,而不管它实际地址是什么值。现在回到dump.exe 的PE头中:
00000080  10 00 00 00 00 00 00 00 00 00 00 00 00 30 02 00   ............0 .
00000090  84 00 00 00 00 C0 01 00 14 0D 00 00 00 00 00 00  ?...?. .......
……………………………………………………
000000E0  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
000000F0  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
00000100  00 00 00 00 00 00 00 00 00 00 00 00 00 B0 01 00  .............?.
00000110  00 10 00 00 00 B0 01 00 00 10 00 00 00 00 00 00  . ...?.. ......
00000120  00 00 00 00 00 00 00 00 E0 00 00 C0 00 00 00 00  ........?.?...
00000130  00 00 00 00 00 70 00 00 00 C0 01 00 00 70 00 00  .....p...?..p..
00000140  00 C0 01 00 00 00 00 00 00 00 00 00 00 00 00 00  .?.............
00000150  E0 00 00 C0 2E 69 64 61 74 61 32 00 00 10 00 00  ?.?idata2.. ..
00000160  00 30 02 00 00 04 00 00 00 30 02 00 FF 13 73 1F  .0 .. ...0 .?s
00000170  B6 80 41 B0 10 FF 13 12 40 00 00 C0 3A AA EB E0  ?A?? @..??
  
  该头变化不大,只是增加了一个.idata2区段,该区段数据结构中居然还有不少垃圾代码。不管这些。该作下列工作:
  (1)登记IID表(导入表):PE头中所有双字都是按地址尾数为0、4、8、C对齐的。PE头中有惟一一个特殊的双字是10000000(它表示16个节表的开始)。也就是说只要在地址尾数为0、4、8、C的某个位置找到10,节表就从这里开始。本程序是在0000080。它后面的第一个8字节是导出表,不管。第二个就是导入表,正是要找的:它已经有0030020084000000了,它没用,是OD搞错了乱写的(笑,的确是OD乱点鸳鸯谱),填写上:40A00000 50000000。前者是改造的IID表的开始位置,后者是它的长度。(再次强调:不管实际IID表在什么地址,只要装载后是004040A0,减去400000就是要登记的地址,老比是不会帮你计算RVA的。)
  (2)登记IAT表(导入函数地址表):前面修改的那个IAT表装载后地址是00404000,长度是80。它是16个节表中的第13个。从双字10000000后开始按8字节一组,数到第13组,就是该表所在位置,即在000000E4开始的位置处填写上:00400000 80000000,存盘。
  OK,全部工作完成!正准备收工时,我又一次“突然想到”……,windows装载函数地址时加上的那个400000会不会用or 400000,如果是这样,前面那些去掉40的作法不是都没必要了吗?我忙重复实验了一次,不全对也不全错……。但前面的修改工作却更加简化了!(你自己实验吧!)
  运行存盘后的dump.exe,成功了!它所调用的所有函数在OD上显现无遗,代码每步可见。不过程序资源部分还不能用eXeScope编辑。这次累了,下次争取把资源部分也解密出来,它肯定已经解压了,只是加了密。
  谢谢你把它读完!最后我想起高中时数学老师对我说过的一句话:“学数学重要的不是学它的公式和算法,而是学它的思想”。现在才似乎若有所悟。