郁金香2021年游戏辅助技术中级班(一)
- 用代码读取utf8名字
- 字节数组搜索UTF-8字符串
- 用CE和xdbg分析对象名字
- 从LUA函数的角度进行分析
- 复习怪物名字偏移
- 用CE和xdbg分析对象数组
- 认识虚函数表
- 分析对象数组
- 分析对象数组链表部分
- 链表的定义
- 链表的数据在内存里面究竟是一个什么样的形式
用代码读取utf8名字
上图从33行开始到43行的那个代码块是UniCodeToAscii函数。
字节数组搜索UTF-8字符串
现在比较新的CE版本是支持搜索UTF-8字符串的:
较老一点版本的CE是搜索不到UTF-8字符串的,当时也是有办法的,我们可以修改上图字符串类型为字节数组类型:
通过搜索上图这个字节数组也是能够找到UTF-8字符串的:
现在我们用的CE7.2这个比较新的版本,它是支持UTF-8的,所以可以直接搜索该字符串。
用CE和xdbg分析对象名字
早期的CE搜不到UTF-8字符串,这里可以用该工具把我们普通的UTF-8字符串转换成字节集的,然后在CE中搜索该字节集。
搜索怪物名字,来找到怪物名字的偏移,但是有可能会搜到很多结果,哪一个才是我们身边选中的怪物呢?
这时候要通过修改名字来过滤筛选了:
发现身边怪物名字没有变化,那就删除选中的这一大片名字(例如二分法),继续再选大部分名字进行修改,不断重复该步骤。
还是没有影响到身边该怪物的名字,删除,继续选择剩下的所有名字进行修改:
注意,再修改名字的时候,不要移动人物、击打怪物,避免刷新地图上的怪物,导致数据过期;另外可以用鼠标来回选中最近的怪物(或者先选中自己,再选那个怪物),切换一下看看选中怪物的血量面板上的名字是否改变(如上图所示,面板上的改变了,模型上的名字没有改变)。
先选中自己,再选最近怪物,如果怪物面板上的名字已改变,删除剩下的部分,只留下受影响的部分,继续二分法修改(少的时候可以从后往前一半一半的过滤),直至确定当前选中怪物名字的地址,然后就可以去找它的偏移了。
我们最好是在xdbg中去分析该地址:
在内存窗口该地址处下个4字节的访问断点,直接断下,可以看到eax中就是2D05A2C8,我们来看看eax的来源:
我们发现[ebp+8]是等于0的,所以eax=[ecx+5c],这是第一个偏移。
我们在内存窗口中查看,ecx+5c刚好就是存放2D05A2C8的地址,所以ecx+5c就是怪物名字的第一个偏移;
而第二个偏移(查找ecx的来源)就要到上一层去看看了(通过堆栈窗口双击返回地址):
也就是上一层的74F857这个地方调用了53B8E0这个获取怪物名字CALL。
而我们要找的ecx就来源于67B6A0这个CALL的返回值,即[CALL 67B6A0的返回值+5C]就是怪物名字地址,所以现在的重点就是要进入到67B6A0这个CALL里面分析该CALL的返回值。
由于该CALL比较复杂,我们先在该CALL调用处下个断来跟进去,分析它是从什么地方返回这个eax返回值的;
首先,我们F8单步步过该CALL,看看[eax+5c]处的内容是不是怪物的名字:
说明我们的找法(分析的位置)是没有问题的,好,我们重新让游戏运行起来,重新断在74F84A地址这个CALL,然后按F7跟进去分析返回值eax,进去后我们一直按F8,去追eax是在什么地方返回的:
上图就说明了所处位置不对,肯定不是从上图这个地方返回的(否则是能够读出怪物名字的),我们继续向下F8跟:
从上图这里来看的话,它还有一个0x18的偏移:
经测试,0x18确实是怪物名字的一个偏移,然后我们再来看一下0F26EF20又是从什么地方来的,我们可以看 - 减号一步一步退回去,看一下0F26EF20这个eax来源于什么地方。
这个eax来源于CALL 6F6020的返回值。
这个CALL 6F6020感觉是到怪物数组里面去把那个怪物对象取出来(可以看到ds:[edx+eax*4+4]
这种全局数组或静态数组的反汇编形式),有点这种感觉,我们在该CALL开头做个标记。
这个CALL我们感觉有些熟悉,在初级班29课分析角色对象的属性那课:
在CALL 4D4DB0查询对象这个CALL里面有个CALL 4D4BB0:
这个CALL 4D4DB0和我们刚才分析的CALL 6F6020很相像。
对比分析,感觉都是一个模式印出来的,相似度都是非常高的,只是这两个CALL的地址不一样,从代码来分析的话基本上都是一样的。
目前我们还是回到CALL 67B6A0分析上来,就是说这个地方有可能就是遍历怪物数组的地方,遍历完就返回这个怪物对象,基本上就是返回的这个对象+0x18+5C就是怪物名字的地址了;
而且我们在74F84F地址下断,发现返回值eax总是固定的值F0FFCF8(而F0FFCF8-0x18就刚好是上图内层CALL 6F6020的返回值F0FFCE0),即使我们将视角变化一下,可视范围内怪物对象多一些,返回值eax也不变,可能是由于这是选中的那个怪物对象,那么我们换一下怪物看看,甚至选中远处的怪物,结果我们发现返回值eax还是不变,这就比较奇怪了,返回的老是相同的一个数值,这需要下来之后作进一步的分析了。
接着我们看一下传给CALL 6F6020的参数是什么,都是什么内容,猜测都是干什么用的:
我们在内存窗口中转到当前esp,然后用64位十六进制整数查看,这是为了分析该数值是否是怪物的ID:
然后用CE搜一下3ACFD370000012B这个8字节的数值:
发现只有一处地方有这个数值,目前还不知道该数值是不是怪物的ID(只有猜测)。
有可能CALL 6F6020这个CALL是用怪物的ID来查询怪物的名字,具体我们下去再分析一下。
我们用CE搜一下什么地方存了eax=0F20E4F0这个指针:
发现搜到的结果比较多,那么我们进到0074F84A地址处的CALL 0067B6A0里面:
我们发现上图堆栈窗口中每次断下CALL 006F6020的时候传入的参数2会发生变化,ECX没有变动。
从LUA函数的角度进行分析
实际上另外有一种方法可以快速的分析到,通过游戏注册的LUA函数:
在UnitName函数这里,如果传入的是选中的对象,也可以跟到这里。
我们看一下eax的返回值,因为这是个LUA的函数,最终会返回一个名字,如上图所示这时候返回的是玩家的名字。
我们先把断点删除,让游戏跑起来,选择一个怪物,然后在上图LUA函数开头下断,重新断一下再看看它返回的目标又是什么。
我们也可以在游戏中聊天窗口那里运行宏/run UnitName("target")
,如下图所示准备好该LUA指令:
由于怪物一直在攻击我,该LUA函数这里不好下断,所以我们要在不被攻击的情况下选中目标(可以选稍远一些的):
内存窗口中转到eax的值,可以看到目标名字就是从CALL 4FD0E0这个CALL返回的,在这个CALL里面必定能找到怪物的名字和偏移。
我们在该CALL下断,然后在游戏里输入lua指令/run UnitName("target")
,回车就会断在该CALL,此时eax=2D1AEF90,我们记下来,然后继续F8单步到CALL 4FD0E0:
可以看到此时ecx=2D1AEF90,会作为参数传入CALL 4FDDE0,我们跟进该CALL里面,看一下名字是从什么地方来的:
继续往下跟:
怪物名字应该是从CALL 72A000这个CALL里面来的,我们跟进去:
走到这里的时候,ecx=2D1AEF90仍然是我们的怪物对象:
怪物名字的两个偏移一下子就出来了。
UnitName这个LUA的接口就是显示我们选中目标(参数为"target")的名字,当然它实际不会显示出来,如果你要显示出来可以输入/run print(UnitName("target"))
:
如果选中自己就会显示角色名字:
选中NPC同理,用LUA指令就很简单了。
另一条分析的路径(就是那个+0x18)应该也是能够走通的,具体我们还要以后继续分析该路径,难点就是6F6020这个CALL比较复杂,它里面存在很多数组操作。
复习怪物名字偏移
我们以NPC五毛为例:
搜到之后对名字进行修改,这时候我们选自己,然后再选五毛,查看角色血量面板,可以看到NPC五毛的名字已经被修改了,NPC人物模型头上的名字变,为了五23(一定不要去看人物头顶上的名字),而面板上的变为五毛11,我们这里要以面板显示的为准。
我们把五毛11改为五毛12,就确定了CE搜到的怪物名字的地址。
我们再搜一下存放这个名字的地址:
这样就找到了它的第一个偏移+5C,然后我们可以继续搜esi的值,也可以显示反汇编程序看一下附近有没有更上层的偏移:
可以看到它有另外一个偏移+964。
接下来我们用xdbg接着分析:
在wow.exe后面加一个点和一个数字零,即wow.exe+32A27F
就可以在xdbg中直接转到该地址处:
我们打开内存2窗口,转到esi的地址处:
如上图所示复制esi所在地址处开始的内容,这是一个很重要的特征,esi此时的地址值里面保存的内容A34D90是判断它是否是一个对象的关键,虽然它不能用来判断是否是怪物对象,但是如果A34D90是虚函数表vftable的地址,那么这个esi大概率就是一个对象;
因为对象一般它的首地址大概率存在虚函数表vftable,当然不是绝对的,只不过游戏里面怪物对象或者游戏对象的首地址如果是一个虚函数表vftable的话,那么它们就是一个对象,到底是不是,我们在xdbg中内存3这里查看分析一下:
可以看到这两个地址都是一个标准的函数头。
这样就能够确定A34D90就是一个虚函数表vftable,或者说是虚函数指针。
这样的话,我们就得到了怪物名字的两个偏移了。
通过这个虚函数指针确定它是一个对象,我们就不用继续找了,找这两个偏移就可以了,一个+964,一个+5C,最终就可以读出怪物名字了。
一定不要去看人物头顶上的名字,否则你是找不到的,要看面板上的信息。
用CE和xdbg分析对象数组
认识虚函数表
创建基于对话框的MFC应用。
我们在项目文件目录中新建一个怪物类的目录(筛选器),创建一个怪物的类,用来模拟怪物对象:
当然这个模拟的怪物数组对象和我们游戏里的不同,因为它直接就是一个全局变量,它没有偏移,直接就是一个基址。
用x32dbg打开该exe文件,转到MessageBeep函数那里下断,运行后点击按钮就会中断在这里:
在双击右下角堆栈窗口中的返回地址,就可以返回到我们代码那里了:
这里直接就能看到怪物数组,在内存窗口中我们看一下怪物数组的对象,它首地址里面有一个注释vftable(当然游戏中是没有这个注释的),实际上就是虚函数表指针:
通过这个虚函数表指针0132898C,进到该地址处才是虚函数表:
这里面每一个其实都是一个函数的地址:
这里面大部分都是我们继承的CDialog基类里面的成员函数,这是我们为了模拟分析虚函数表才选择它来继承:
某一种怪物它们的虚函数指针一般都是相同的,所以说我们还可以用这个虚函数表来区分不同类型的对象,比如说玩家的和怪物的虚函数表指针有可能不同,如果它们真是同一个怪物类的话,那有可能就是相同的,而且很多功能也可能在这个虚函数表里面。
这对我们分析对象是有很大帮助的,因为一旦我们得到类似地址进去之后,就能发现这可能是一个对象的头部,因为这种对象的头部可能会存在类似这种一大片的函数指针。
我们来看一下游戏中的玩家对象(角色对象):
现在eax就是我们的角色对象,我们转入00A326C8里面看看,因为理论上来说00A326C8就是所谓的虚函数表指针:
这些每一个都是一个函数的头部,所以说,我们在区分对象的时候,如果它的头部是一个虚函数表指针,那么它具有这种非常明显的特征,而00A326C8就是虚函数表地址(也就是虚函数表指针),而且我们看到角色对象里面的成员函数有很多,有的是用来绘图的,有的是用来获取某些数据的(血量、属性等)。
我们要看到对象具有这种特征,我们这节课主要就是要了解怪物对象或者是玩家对象,如果它是一个对象,它的第一个地址进去就是一片函数,这样能够让我们确定找到的这个东西它是一个对象,比如说怪物对象或者玩家对象。
你就简单的知道,对象的第一个地址进去,里面的一大片全部都是函数的地址,就可以确定它就是一个对象;
就是说,这个指针可以帮助我们确定我们找到的这个是对象的首地址,因为对象的首地址它具有这个特征。
这个0x1372C88是怪物数组的基址,0x1D0是怪物类的大小。
我们从上面两张图可以看到,怪物数组[0]和怪物数组[3]这两个对象,它们具有相同的虚函数表指针0136898C,因为它们是同一个类。
我们看到这些对象虚函数表指针下面都是大片的0,这是因为我们没有为该对象里面的成员写数据,我们给其中某些成员赋值来对比看看:
这里简单介绍一下xdbg条件断点:
条件断点一般是下载频繁断下的地方,例如窗口过程,还可以结合逻辑与&&、逻辑或||、逻辑非!来添加多个判断条件。
分析对象数组
我们就从0x60C1F0进去用xdbg分析怪物对象数组。
我们要分析怪物数组的话,可以从怪物的名字去找怪物对象,这样慢慢的追到怪物数组,另外我们也可以通过玩家对象的来源去追这个怪物数组。
前面我们也说了,大部分游戏中怪物和玩家对象、NPC、掉落的地面物品,一般都是放在同一个数组或者链表里面的,所以说玩家对象是从0x601CF0这个CALL里面返回的,但是这个玩家对象是不是也包含在怪物数组里面呢。
所以我们可以从玩家指针的来源去找这个怪物数组,所以我们要从0x60C1F0这个CALL里面来找。
打开角色信息,断在该CALL开头,我们一直按F8看看最后返回的eax是从哪里来的:
如果是我们玩家对象,就会有一个明显的特征,就是[玩家指针+0D0]+174是否是玩家的护甲值,所以返回值eax就是从004D4DB0这个查询对象CAL里面来的,现在我们基本上就能够确定这个CALL就是玩家对象的来源,也就是说更深层的CALL就是从004D4DB0里面来返回的我们角色对象。
该玩家对象首地址中的值00A326C8其实就是之前讲的那个虚函数表首地址,00A326C8里面存放的全都是独立的一个一个的函数:
我们到4D4DB0那个位置去看看究竟返回值eax是从什么地方返回的:
我们之前分析过4D4DB0这个CALL的参数,我们怀疑第1个和第2个参数是ID1、ID2,用来查询那个对象数组的,通过这两个参数来查询返回的对象,也就是说通过这个CALL应该也能返回其他的怪物对象。
我们继续往该CALL里面跟一下:
可以看到玩家对象是从4D4BB0这个CALL里面来的,所以说关键的那个数组可能还在这一层CALL里面。
但是之前我们说过这个CALL,它是一个非常干净的一个CALL,里面没有再调用其他函数,可以从上图看到这里面有一个循环,这就有很大的概率是在循环的做一个遍历。
我们先来看一下4D4BB0这个CALL的参数:
第1个参数是0xC,第2个参数edx=039FF6B0是ebp-8的地址,从内存窗口转到该地址(如上图所示的039FF6B0),可以看到里面存放的是一个0xC和一个0。
在4D4BB0函数尾部下断,然后不断点运行,观察发现返回值eax大部分时候不变,但偶尔会变,我们来看看[2CFD05C8+D0]+174里面是什么(如上图左下角内存窗口中的内容),可以看到这个地方明显就不是护甲值了,可能是一个其他的什么东西,我们把这个对象和玩家对象里面的数值做一个比较。
我们在4D4BB0函数尾部这个地方下断,主要是怀疑从这个地方会返回怪物对象或者是其他的对象,但是我们不能直接在4D4BB0这个地方下断,否则游戏会一直断下直到卡死掉,所以需要在上一层下断,然后再在这里下断,按运行断到这个地方。
我们在内存窗口中转到[eax+D0]+174
来看看,如果是玩家的话这个地方就会是护甲值,此时证明2C318C50是角色对象,如果返回的是其他值的话可能就是怪物对象了:
我们发现如果不是玩家(此时eax=2D4E79F0,而[2D4E79F0+D0]+174地址处的内容如上图所示为0),这个地方就没有护甲值。
我们再来对比角色对象2C318C50和另一个对象2D4E79F0在内存窗口中的内容:
我们可以看到不同对象的虚函数表指针也是不一样的,说明它们属于不同的类型,那么这个对象2D4E79F0究竟是什么呢?
我们之前分析了怪物的名字(/run print(UnitName("target"))
),当时我们分析到了两个偏移,+964和+5C:
我们用这个公式[[eax+964]+5C]
来看看这个对象2D4E79F0有没有怪物名字:
这就确定了这个对象2D4E79F0是怪物对象,所以从4D4BB0这里返回的一个是角色对象,另外一个是怪物对象,当然可能也有其他的对象(坐骑、地面物品、矿物药草之类的),究竟还可以返回哪些类型的对象就需要进一步的分析,从目前来看至少有这两种对象。
接下来我们就要看看这个怪物对象或者玩家对象究竟是从4D4BB0这个CALL里面的什么地方来的:
我们可以看到ecx是很明显的一个关键,这个edx应该是数组的基址,这个eax应该是某一个数组的下标。
那么ecx+1c是什么呢?我们返回上一层看一下这个ecx的来源:
从上图我们可以看到ecx最终来源于ds:[edx+8],而edx的来源又与eax这个数组下标相关,eax的来源ds:[D439BC]的内容从左下角内存窗口中看到是0,所以edx=[ecx+eax4]=[ecx+04]=[ecx],所以关键又回到这个ecx=fs:[2C]上了,这个fs:[2C]之前我们也讲过,你就把它看成是一个普通的内存地址就可以了(这个线程的TEB在内存中映射的地址),我们把fs:[2c]代入公式里面,所以edx=[ecx]=[fs:[2c]],即最终ecx=[[fs:[2c]]+8]就得到了一个新的表达式了。
我们来验证一下:
内存窗口中我们通过表达式[[fs:[2c]]+8]转到的地址处是136FC3A0,而ecx寄存器的值也正好是136FC3A0。
我们先进4D4BB0这个CALL里面,我们用一个等价替换,把下图中的eax=[ecx+24]用ecx的表达式[[fs:[2c]]+8]替换,得到eax=[[[fs:[2c]]+8]+24]
,我们跟进4D4BB0这个CALL里面来验证一下(记得要断在这里,才能在游戏的主线程中,得到的fs:[2c]才是正确的):
可以看到eax的值为1F,而公式[[[fs:[2c]]+8]+24]
的值如上图所示确实也是1F,就证明这个公式是正确的,我们接着往下执行:
证明edx=[ecx+1c]=[[[fs:[2c]]+8]+1c]这个表达式也是正确的,这里的这个edx就是比较关键的地方,就是那个数组的地址了。
我们继续往下看:
eax的值1F的十进制是31,从这里and eax, esi
来看这个31可能就是最大的数组上限,它把多余的超出31的那部分值给截断掉(例如上图我们把esi=0000000c修改为esi=1234567c,以验证and eax, esi这条指令的效果),最终得到eax=c,来保证是在数组元素个数范围内。
(验证完记得把esi的值恢复为0000000c保证程序的正常环境)
我们继续来做等价替换:
在执行完and eax, esi
指令后eax的值为0c,所以实际上eax就是传入的参数1了(eax有可能是下标):
上图这个公式可能就是最终的怪物数组了,我们把参数1替换为0,最终的公式就是[[[[fs:[2c]]+8]+1c]+8+0*0c]
,计算得到2D4F5960,而2D4F5960里面的内容是:
这确实是一个对象,这就证明了我们这个公式确实能取得数组里面的对象。
我们继续往下看代码:
发现eax在下面的代码中没有被重写过,所以最终返回的就是这个eax的值。
我们再来试一下怪物的名字(名字的偏移是+964,再+5c):
如果是怪物那么就能读出怪物的名字,如果是玩家或者其他类型的对象,那么有可能是读取不出来的,从上图计算得到的数值可以看到目前是能够正确读取出来,这个索引为0的数组中第一个元素可能就是我们的怪物对象。
记得要断在这里,才能在游戏的主线程中,得到的fs:[2c]才是正确的,才能通过上述公式得到正确的结果(否则是访问不到的,必须是在主线程中才行)。
下标改成2我们就可以看到,这里可能也是一个对象,但是名字显示不了(该对象没有名字或者名字不在这个偏移位置),能够确定它不是怪物对象,我们修改公式来看看它是个什么对象:
看起来像是我们的玩家对象,如果是玩家对象的话,我们修改公式来查看玩家的护甲值:
结果看不到护甲值,说明这个对象既不是我们的玩家对象,也不是怪物对象,可能是其他类型的对象。
我们发现用该公式(此时当索引为2)还能取得NPC的名字,说明NPC和怪物可能是同一个类,为了证明这一点,我们查看一下对象的虚函数表指针:
上图这个A34D90是NPC的虚函数表指针。
我们知道索引为0的是怪物的,而它的虚函数表指针也是A34D90:
说明怪物和NPC是同一个类。
除了索引0和索引2,剩下的都不是,直到索引为1A的才有内容:
索引为1A的对象是无名这个NPC,该对象的虚函数表指针也是A34D90。
我们分析出来的这个怪物列表其实还不全(涉及到链表),这里我们先讲它的一部分,后面我们还会继续详细化全面分析怪物列表。
代码下面还有一个遍历链表的循环,里面应该还包含有其他的怪物对象,通过这个循环去进行遍历的:
这个fs:[2C]是一个特殊的内存地址,也就是这个线程的TEB在内存中映射的地址:
分析对象数组链表部分
在C++环境里面链表是怎么创建的,而且看一下链表的数据在内存里面是一个什么样的存放方式。
链表的定义
链表的数据在内存里面究竟是一个什么样的形式
// CreateList.cpp : 此文件包含 "main" 函数。程序执行将在此处开始并结束。
//#include <iostream>// 操作系统 win7 64
// 编译环境 Visual Stuido 2017#include<stdio.h>
#include<malloc.h>
#include<stdlib.h>typedef int ElementType; // 定义数据类型,可根据需要进行其他类型定义
//class // 链表节点的定义
typedef struct ListNode {ElementType Element; // 数据域,存放数据int data1;int data2;ListNode* Next; // 链表指针 指向下一个链表节点float x,y,z;
}Node, *PNode;// 链表创建函数定义
PNode CreateList(void) {int len; // 用于定义链表长度int val; // 用于存放节点数值PNode PHead = (PNode)malloc(sizeof(Node)); // 创建分配一个头节点内存空间//头节点相当于链表的哨兵,不存放数据,指向首节点(第一个节点)if (PHead == NULL) // 判断是否分配成功{printf("空间分配失败 \n");exit(-1);}PNode PTail = PHead; // 链表的末尾节点,初始指向头节点PTail->Next = NULL; // 最后一个节点指针置为空printf("请输入节点个数:");scanf_s("%d", &len); // 输入节点个数for (int i = 0; i < len; i++) {PNode pNew = (PNode)malloc(sizeof(Node)); // 分配一个新节点if (pNew == NULL) {printf("分配新节点失败\n");exit(-1);}printf("请输入第 %d 个节点的数据:", i + 1);scanf_s("%d", &val); // 输入链表节点的数据pNew->Element = val; // 把数据赋值给节点数据域PTail->Next = pNew; // 末尾节点指针指向下一个新节点pNew->Next = NULL; // 新节点指针指向为空PTail = pNew; // 将新节点复制给末尾节点 }printf("创建链表成功\n");return PHead; // 返回头节点
}// 主函数
int main() {PNode List = CreateList(); //创建一个指针,使其指向新创建的链表的头指针 printf("List链表头=%p\n", List);getchar();return 0;
}
可以看到List这个链表头里面的数据是未初始化的,它只有Next被初始化为指向首元结点,而首元结点的Element的值就是我们刚才输入的111这个数据,而这个首元结点的Next展开就是指向第二个结点:
最后,当Next指针指向NULL的时候,我们遍历到这里的时候整个链表的遍历就结束了。
接着我们在xdbg中分析链表的反汇编:
添加MessageBeep(1);用来方便定位。
我们在内存窗口中查看链表头7BCAE0里面的内容,发现都是一些未知的数据,我们要找到链表头中的指针域:
所以第1个结点的地址就在+C偏移的指针域位置,它的地址就是7BF950:
所以第1个结点中偏移+C位置的值7BF9A8就是第2个结点:
这就是链表的数据结构在内存里面的存放方式。
上图这是个遍历对象链表的循环,游戏里面采用了这种链表的结构,我们之前讲过的对象数组,里面的元素是一个类对象,该对象里面的成员有可能是一个链表,这么说吧,就是说怪物对象里面有一个成员就是那个链表头。
头结点的指针域Next指向第一个结点(数据域中的数值为3),第一个结点的指针域Next指向第二个结点(数据域中的数值为2),第二个结点的指针域Next指向第三个结点(数据域中的数值为1),第三个结点的指针域Next指向NULL,遍历到这里就结束。