首页
社区
课程
招聘
[原创]实现一个EDR不可见的网络通信(将lwip移植到nt内核中)
发表于: 6天前 1399

[原创]实现一个EDR不可见的网络通信(将lwip移植到nt内核中)

6天前
1399

写这篇文章的灵感来源于对 lwIP 这个轻量级 TCP/IP 协议栈的分析。lwIP(lightweight IP)是嵌入式世界里赫赫有名的一个开源协议栈,它的代码非常精简,内存占用极低,可配置性又很强,因此在单片机、IoT 设备这些资源受限的平台上应用极其广泛。但有意思的是,把这个本该跑在嵌入式系统上的协议栈移植到了 Windows 的内核驱动里,让它能在内核态直接收发网络数据包。这意味着什么?意味着我们可以绕过 Winsock 那一整套用户态协议栈的封装,获得更高的性能和更灵活的控制力。

我们先看看这个移植项目的整体结构吧。

lwIP 是瑞士计算机科学院(SICS)开发的一个开源 TCP/IP 协议栈。它诞生的初衷是为嵌入式系统提供一个功能完整但体积小巧的 TCP/IP 实现。跟 Linux 内核的 TCP/IP 栈比起来,lwIP 的代码量大概只有前者的十分之一,但它一样实现了 TCP、UDP、IP、ICMP、ARP、DHCP、DNS 这些核心协议。

lwIP 最厉害的地方在于它的可移植性。它把自己和操作系统的接口定义得清清楚楚——信号量怎么创建、线程怎么启动、内存怎么分配,所有这些都抽象成一组接口,你只要在 sys_arch.c 里把这些接口填上,lwIP 就能在你的平台上跑起来。所以你能看到 lwIP 被移植到了 FreeRTOS、uCOS、RT-Thread 这些 RTOS 上,也被移植到了 Windows、Linux 这些通用操作系统上,甚至还有人把它移植到了完全没有操作系统的裸机上。

我们这个项目做的就是把它移植到 Windows NT 内核里,而且不仅仅是让它在内核态能跑,还要让它能真正通过物理网卡收发数据。这就要用到 NDIS 和 API Hook 了。

整个项目是分层的,每一层各司其职:

最底层是系统抽象层,提供线程、信号量、邮箱、内存分配这些基础服务;往上是挂钩引擎层,负责拦截 NDIS 的导出函数;再往上是 NDIS 协议框架层,管理微型端口、适配器和协议绑定;最顶层就是 lwIP 核心协议栈本身。数据从网卡进来,经过这些层的逐级传递,最终被 lwIP 协议栈处理。

这个项目有几个很有意思的设计决策。

一是零拷贝接收。接收数据包时,不是把数据从 NDIS 缓冲复制到 lwIP 缓冲,而是直接用 pbuf 引用 NDIS 缓冲里的数据,指针指过去就是了,省掉了一次内存拷贝。对于高速网络来说,这个优化是很可观的。

什么叫零拷贝接收呢?传统的做法是这样的:当 NDIS 从网卡收到一个数据包时,数据首先被 DMA 进一个 NDIS 分配的缓冲区,然后驱动调用 NdisGetDataBuffer 拿到数据指针,再调用 pbuf_alloc(PBUF_RAM) 在 lwIP 的堆上分配一块新的内存,最后用 memcpy 把数据从 NDIS 缓冲拷进这块新内存。这么一来,同样一份数据在物理内存里就有了两份拷贝——一份在 NDIS 那边,一份在 lwIP 这边。网速慢的时候还好说,到了千兆甚至万兆网络,每秒钟几百万个包进来,每个包都要 memcpy 一次,太麻烦了。

这个项目绕过这个问题的办法很巧妙——它用了一个叫做 pbuf_alloced_custom(PBUF_REF) 的 lwIP API 来创建所谓"自定义 pbuf"。这种 pbuf 不拥有自己的数据缓冲区,它只是持有一个指向外部数据的外指针。具体做法是,当 MpIndicateReceiveNetBufferLists 从 NDIS 的 NET_BUFFER_LIST 中取出数据后,调用 NdisGetDataBuffer 直接拿到 NDIS 内部缓冲区的虚拟地址,然后把这个地址塞进一个 pbuf_custom 结构里,最后用 pbuf_alloced_custom 让 lwIP 以为这是一个正常的 pbuf。这样一来,lwIP 的 TCP 栈在处理数据时,实际上是在直接操作 NDIS 的缓冲区,中间没有发生任何数据拷贝。

当然,代价也是有的。因为 pbuf 不拥有数据,所以调用者必须保证在 lwIP 处理完这个 pbuf 之前,NDIS 的缓冲区不能被释放。代码里用了一个引用计数的机制来管理这个生命周期——在 pbuf 的 custom_free_function 回调中释放 NDIS 缓冲的所有权。这样既保证了零拷贝的性能优势,又避免了野指针造成的崩溃。

二是 DMA 散列汇聚支持。对于支持 SG DMA 的网卡,干脆让硬件直接做数据搬移,CPU 可以在旁边歇着。

什么叫 DMA 呢?传统的 CPU 做数据搬移是这样的:CPU 从源地址读一个字节到寄存器,再从寄存器写一个字节到目的地址,循环往复。这个过程叫 PIO(Programmed I/O)。DMA(Direct Memory Access)就是把这个事情外包给专门的硬件——DMA 控制器。CPU 只需要告诉 DMA 控制器迁移什么,迁移到哪里,然后就可以去做别的事了,DMA 控制器会在后台把数据搬完,完了发个中断。

那 SG DMA(Scatter-Gather DMA)又是啥呢?简单来说,普通的 DMA 要求源地址和目的地址在物理内存里是连续的——你要搬 1500 字节,这 1500 字节在物理页上必须是一块连续的区间。但问题在于,NDIS 的数据包缓冲和 lwIP 的 pbuf 链不一定是物理连续的。一个 1500 字节的以太网包,在 NDIS 那边可能分散在两三个不连续的物理页上。如果只用普通 DMA,你得先分配一块连续的缓冲区,把所有数据拷贝进去,再让 DMA 控制器去搬——这又回到了拷贝的老路。

SG DMA(散列汇聚 DMA)解决了这个问题。它允许你把一个数据描述成一个"散列-汇聚列表"(Scatter-Gather List),列表中每一项是一个{物理地址, 长度}对,指向数据在内存中的各个碎片。DMA 控制器自己会按照这个列表,依次把各个碎片的数据搬到网卡上,或者从网卡收来的数据按列表依次写入各个碎片。这样一来,即使数据在物理内存里是东一块西一块的,硬件也能一次性搞定,不需要 CPU 介入做合并。

这个项目在 dma.c 里实现了一套三级降级策略来应对不同硬件的能力:

这套策略保证了无论硬件能力强弱,代码都能正常工作。

三是 NDIS 5/6 双模式兼容。NDIS 5 用 Packet API,NDIS 6 用 NetBufferLists API,代码里两套路径都有,遇到什么版本的驱动都能处理。

四是内核态 API Hook。这个是最难搞的部分,直接改 CR0 寄存器的 WP 位关掉写保护,往 ndis.sys 的导出函数入口写 JMP 指令,把调用重定向到自己的处理函数。当然,在 x64 Windows 10/11 上这么做会触发 Kernel PG。后面我们会详细说这个。

五是多介质支持。不止是 Ethernet 802.3,WAN、FDDI、IrDA、WirelessWAN 等 12 种 NDIS 介质类型都覆盖到了。

驱动入口非常精简,总共也就三四十行。DriverEntry 里干了五件事:

初始化顺序是有讲究的:lwIP 先启动(创建 tcpip_thread),然后初始化 NDIS 接口(准备好全局链表),再初始化协议子系统(分配 Lookaside 缓存),最后才安装挂钩。任何一步失败了,前面的操作都会回滚。卸载的时候顺序反过来就行:

这是移植最核心的一个文件。lwIP 要求平台提供信号量、邮箱、线程、临界区保护这些基础设施,sys_arch.c 就是在 Windows NT 内核里实现这些东西。

KeQuerySystemTime 返回的是 1601 年 1 月 1 日以来经过的 100 纳秒间隔数,减去驱动加载时记录的 StartTime,再除以 10000,就得到了从驱动加载到现在过去了多少毫秒。精度是 100 纳秒,远超 lwIP 要求的毫秒级精度。

这里用了一个全局自旋锁,把当前 IRQL 保存到 lev 里,释放的时候再恢复。优点是实现简单,缺点是全局锁在多核高并发下可能成为瓶颈。不过在驱动开发场景下,这个实现是够用的。

SynchronizationEvent 类型保证一次只唤醒一个等待线程。count=1 时事件初始为 signaled(资源可用),count=0 时初始为 non-signaled(资源被占)。

信号量等待函数有一个很精妙的设计,它不光等信号量本身,还等一个 TerminationEvent:

为什么要这么设计呢?根本原因在于 Windows 内核中无法安全地强制终止一个正在等待中的线程。如果你直接调用 PspTerminateThreadByPointer,线程可能正持有某个锁、正在操作某个数据结构,强行杀掉它会导致系统状态不一致,轻则内存泄漏,重则死锁蓝屏。所以这里的策略不是"杀掉线程",而是"告诉线程自己退出"。TerminationEvent 就是这扇"告诉"的门——当 sys_shutdown 设置这个事件后,所有通过 KeWaitForMultipleObjects 等待信号量的线程都会在 wait 返回时发现第二个对象被触发了,于是从等待状态苏醒,走各自的清理路径后自行结束。这个模式在 Windows 内核编程中叫做"关闭事件模式"(Shutdown Event Pattern),是驱动开发里优雅关闭线程的标准做法。

邮箱是 lwIP 线程间通信的核心。tcpip_thread 就是从这个邮箱里取消息来处理的。实现上用了三个要素:一个 LIST_ENTRY 链表存消息,一个 KEVENT 做等待,一个 KSPIN_LOCK 保护并发:

发消息时分配一个 container,加锁插入链表尾部,设置事件唤醒等待者。收消息时等事件,加锁取出头部消息,如果链表空了就清除事件。

线程包装函数 LwipThreadMain 负责把线程信息插入全局链表、调用实际线程函数、从链表移除自己、释放内存,最后 PsTerminateSystemThread(STATUS_SUCCESS) 收尾。

关闭的时候,sys_shutdown 先设置 TerminationEvent 通知所有线程退出,然后遍历线程链表等待每个线程真正结束,最后关闭句柄。

lwIP 默认自带内存堆管理器,但我们在 lwipopts.h 里配了 MEM_LIBC_MALLOC=1,让它用标准的 malloc/free 代替。rosmem.c 就是在内核里实现这些函数:

所有分配用 NonPagedPool(非分页池),因为驱动在高于 DISPATCH_LEVEL 的 IRQL 下运行时不能触发页错误。LWIP_POOL_TAG = 'PIwl' 用于 WinDbg 的 !poolused 命令追踪内存泄漏。

把 lwIP 的初始化和关闭封装成两个函数:

tcpip_init 会调用 sys_init() 初始化 OS 抽象层,然后创建 tcpip_thread 和 tcpip_lock。注意 tcpip_init 是异步的——它返回后 tcpip_thread 可能还没完全就绪,所以网络接口应该在初始化完成之后再加。

为什么 tcpip_init 要设计成异步的呢?这跟 lwIP 的线程模型有关。tcpip_init 的核心工作是在一个单独的线程——tcpip_thread——中运行整个协议栈的事件循环。这个线程负责处理所有协议相关的操作:TCP 连接管理、数据收发、定时器触发等等。如果 tcpip_init 是同步的,那就意味着它要等 tcpip_thread 启动完毕、所有子系统初始化完成、协议栈进入就绪状态之后才返回。但问题在于,tcpip_thread 的事件循环本身是无限循环,它永远"就绪"的标准很难定义——是线程创建成功了就算?还是第一个消息循环跑完了算?还是 TCP 监听端口打开了算?不同的上层调用者对"就绪"的定义是不同的。

所以 lwIP 的设计者选择了更灵活的方式:tcpip_init 只负责创建线程和初始化内部锁,然后立即返回,让调用者继续做自己的事。tcpip_thread 在后台慢慢完成剩余的初始化。调用者什么时候认为可以开始使用网络了,就什么时候去添加 netif 或者发消息。这种异步设计也带来一个好处:驱动入口(DriverEntry)不会被阻塞太久。Windows 内核要求 DriverEntry 在合理时间内返回,如果在里面等 tcpip_thread 初始化,可能会超时。异步初始化正好规避了这个限制。

当然,异步也意味着调用者需要自己处理好同步问题。所以在 ProtocolRegisterInterface 中,调用 netifapi_netif_add 而不是直接调用 netif_add——netifapi_* 系列的内部机制会把请求封装成消息发到 tcpip_mbox 中,由 tcpip_thread 串行处理,天然地解决了竞态条件。

这是 lwIP 和 NDIS 之间的桥梁,也是整个移植中最薄但也最关键的一层。lwIP 的协议栈是纯软件实现,它不知道什么是 NDIS、什么是网卡、什么是中断——它只知道 struct netif 这个抽象的网络接口。ethernetif.c 的任务就是把 NDIS 那边的硬件能力翻译成 lwIP 能理解的 netif 接口。翻译得好不好,直接决定了数据能不能顺畅地在协议栈和网卡之间流动。

具体来说,它实现了三个核心函数。

第一个是 low_level_init,在 netif 注册时被调用,负责从 NDIS 适配器读取 MAC 地址、MTU 这些硬件参数,然后填进 netif 结构体。这里有一个值得注意的细节:它根据介质类型动态决定是否启用 ARP。以太网(NdisMedium802_3)需要 ARP 来解析 IP 到 MAC 的映射,所以加上 NETIF_FLAG_ETHARP 标志,接口名命名为 en。WAN 链路则不需要 ARP,接口名用 wa。这个区分很重要——如果给 PPP 链路配上了 ARP,协议栈会不断地发无意义的 ARP 请求,白白浪费带宽。

第二个是 low_level_output,负责把 lwIP 的数据包发出去。它本身非常薄,拿到 pbuf 之后直接调用 pProto->PSendBuffer 委托给 ProtocolSendPBuffer 去处理。为什么这么薄?因为发包涉及 NDIS 5/6 的选择、DMA 路径的选择、数据包池的管理,这些逻辑都集中在 protocol.c 里更合理,ethernetif.c 不需要重复实现。

第三个是 ethernetif_init,作为 netif_add 的回调被调用,负责把上面两个函数挂到 netif 的正确位置。它还根据介质类型选择 output 函数:以太网介质用 etharp_output(先查 ARP 缓存再发),非以太网介质(WAN、IrDA 等)用 nonarp_output(直接发,不做 ARP 解析)。nonarp_output 的实现很简单——忽略 ipaddr 参数,直接调 low_level_output:

这层设计之所以薄,是因为它的定位就是"适配器",而不是"实现者"。它不关心数据包怎么组装的、不关心协议栈内部的状态机、不关心 NDIS 的细节——它只做一件事:把 lwIP 的 netif 和 NDIS 的协议状态绑在一起,让数据能够在两者之间流动。这种职责分离让移植的每一层都可以独立测试和调试。

low_level_init 从 NDIS 适配器读取 MAC 地址和 MTU,并根据介质类型设置 ARP 标志和接口名称:

low_level_output 是个薄包装,实际发包逻辑委托给 ProtocolSendPBuffer。

有意思的是 nonarp_output——对于 WAN、IrDA 这些非以太网介质,ARP 没有意义,所以直接调用 low_level_output 跳过 ARP 解析。

ethernetif_init 根据介质类型选择 output 函数:以太网用 etharp_output,非以太网用 nonarp_output。

这是整个项目最烦人的部分。minihook 是一个通用的内核级函数挂钩库,能在运行时修改 ndis.sys 的导出函数入口,把调用重定向到我们自己的处理函数。

注意,这里的挂钩会触发PG和HCVI的检查

挂钩函数的信息保存在 HOOK_FUNCTION 结构里,包括目标模块名、函数名、替换地址和原始地址。HOOK 结构多加了一个链表节点用于全局管理。

挂钩的核心是在目标函数入口写 JMP 指令。x86 下用 5 字节的 JMP rel32,x64 下用 14 字节的 JMP [RIP+offs]——因为 x64 不支持直接 64 位的相对跳转,得用 RIP 相对间接跳转。

CR0 的位 16(WP)控制内核能不能写只读页面。清掉 WP 就可以修改 ndis.sys 的代码段。改完之后恢复 WP,然后 __faststorefence 保证所有 CPU 都看到修改,KeInvalidateRangeAllCaches 刷新指令缓存。

MakeCallStub 生成一段代码,在跳转到处理函数之前把上下文参数压栈。

GetModuleBase 用 ZwQuerySystemInformation(SystemModuleInformation) 遍历已加载模块,按名称匹配找到目标模块基址。然后 SetHook 解析 PE 导出表,找到目标函数的地址,写入 JMP 补丁。

这段代码在现代 Windows 10/11 下有什么问题呢?主要有三个方面。

第一个问题就是之前反复提到的 Kernel PatchGuard(KPP)。从 x64 版本的 Windows 开始,微软加入了这个内核保护机制,它会定时扫描内核代码段(包括 ndis.sys 的 .text 区),如果发现任何被修改的地方——比如我们写进去的 JMP 指令——就会直接触发 CRITICAL_STRUCTURE_CORRUPTION 的 bugcheck,也就是蓝屏。PatchGuard 的扫描是随机的、多线程的,你根本不知道它什么时候会检查,所以这种 inline hook 在开启了 PatchGuard 的系统上随时可能暴毙。

第二个问题是 Hyper-V 和 Windows Defender 的进一步限制。如果系统启用了 Hyper-V 的基于虚拟化的安全(VBS)或者内存完整性(HVCI),内核代码段所在的页面会被 Hyper-V 的 hypervisor 标记为只读,连 CR0.WP 都不好使了——因为写保护现在是在虚拟机管理器层面实施的,你改 CR0 只能骗过本地的 CPU,但 hypervisor 会拦截实际的页面写入。在这种情况下,WriteProtected 函数执行 __writecr0 之后去写内存会直接触发访问违规,驱动直接崩溃。

第三个问题是 Windows 内核版本差异。ZwQuerySystemInformation 这个 API 在 Windows 10 之后已经被标记为不推荐使用,虽然还能调用,但微软在后续版本中限制了一些调用者的权限。另外 ndis.sys 的导出表在不同 Windows 版本之间也有变化,有些函数在新版本中可能已经不是通过名称导出的了,而是通过编号导出(EAT-only),这样我们的 _stricmp 名称匹配就找不到目标函数了。

所以总体来说,这套挂钩方案在 Windows 7 上跑得很稳,但在现代 Windows 上需要面对 PatchGuard、HVCI、导出表变化这三座大山。

在 minihook 的基础上,ndishook 定义了 11 个 ndis.sys 导出函数的挂钩,涵盖微型端口注册、初始化、数据收发、状态指示等关键操作。

当真实网卡驱动调用 NdisMRegisterMiniportDriver 注册自己的时候,挂钩函数会拦截它:

核心思想是:NDIS 不直接管网卡硬件,是各个网卡厂商的微型端口驱动在管。挂钩 NdisMRegisterMiniportDriver 后,我们可以替换微型端口驱动的 InitializeHandlerEx、HaltHandlerEx 等回调函数。

这样做的好处在于,我们完全绕过了注册表配置和驱动签名问题。如果按照正常的 NDIS 中间层驱动来做,你需要写一个 INF 文件,在注册表中注册 UpperBindings 之类的键值,还要给驱动签名才能加载。而且中间层驱动要处理各种 bind/unbind 的状态转换,跟系统协议栈的交互也很复杂。挂钩的方式就简单粗暴多了——不管系统里装了什么网卡驱动,只要它调用 NdisMRegisterMiniportDriver,我们就拦截、替换回调、注入自己的逻辑。整个过程对网卡驱动本身是透明的,它甚至不知道自己被劫持了。

my_MiniportInitializeEx 的调用链:微型端口驱动初始化成功 → 创建 KIP 适配器 → 调用 MpInitialize 初始化 lwIP netif → ProtocolBindAdapter 绑定协议。

这里的设计非常巧妙:挂钩不阻断数据流。它先偷看数据包送给 lwIP,然后继续把数据传给系统原本的 NDIS 协议驱动。lwIP 和系统 TCP/IP 栈并行运行。

通过挂钩 NdisMIndicateStatus 和 NdisMIndicateStatusEx,驱动可以跟踪网络连接和断开事件,这对 DHCP 自动配置和链路感知很重要。

具体而言,当微型端口驱动检测到网线插入或者无线网络连接建立时,它会调用 NdisMIndicateStatus 发出 NDIS_STATUS_MEDIA_CONNECT 状态指示。挂钩函数拦截到这个指示后,清除适配器上的 fADAPTER_INACTIVE 标志,并递增 MediaConnectCount 计数器。反过来,网线拔出或者无线信号丢失时会触发 NDIS_STATUS_MEDIA_DISCONNECT,挂钩函数设置 fADAPTER_INACTIVE 标志,递增 MediaDisconnectCount 计数器。

这个状态追踪的意义在于,lwIP 的 DHCP 客户端需要知道链路是否在线。如果链路断开,DHCP 续约请求发了也是白发,只会浪费带宽和增加重传超时。通过跟踪 MediaConnectCount 和 MediaDisconnectCount,驱动可以判断链路是否处于稳定连接状态,从而决定是否让 DHCP 客户端发起请求。另外在 MpInitialize 中初始化完成后,驱动也会根据当前的链路状态来设置适配器标志,确保初始状态和硬件实际状态一致。

定义了整个架构的核心数据结构。数据结构层次是这样的:

辅助函数包括适配器查找(通过 NDIS 句柄或微型端口上下文)、OID 查询/设置、数据包拷贝等。全局链表用自旋锁保护,线程安全。

这是一个未文档化的头文件,逆向定义了 Windows 7 ndis.sys 的内部结构。主要用于获取 DMA Scatter-Gather 支持的内部字段。

通过强转 NDIS_HANDLE 为 PNDIS_MINIPORT_BLOCK_WIN7,然后访问 MiniportSGDmaBlock 字段,就能拿到 DmaAdapterObject。这个技巧在 DMA 支持中至关重要。

这是把 lwIP 的 pbuf 数据真正塞进网卡的关键模块。它负责三件事:初始化协议子系统、向 lwIP 注册网络接口、以及实现实际的发包路径。


传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!

上传的附件:
收藏
免费 7
打赏
分享
最新回复 (9)
雪    币: 5214
活跃值: (5977)
能力值: ( LV4,RANK:50 )
在线值:
发帖
回帖
粉丝
2
七八年前用ndis filter做过类似的东西。。。
6天前
0
雪    币: 3315
活跃值: (2887)
能力值: ( LV6,RANK:90 )
在线值:
发帖
回帖
粉丝
3
fengyunabc 七八年前用ndis filter做过类似的东西。。。
师傅你那个东西的协议处理是自己写的吗,还是像这个用的lwip
6天前
0
雪    币: 5214
活跃值: (5977)
能力值: ( LV4,RANK:50 )
在线值:
发帖
回帖
粉丝
4
TurkeybraNC 师傅你那个东西的协议处理是自己写的吗,还是像这个用的lwip
自己搞的,效果肯定没有lwip好,不过能稳定双向通信。
6天前
0
雪    币: 3085
活跃值: (2804)
能力值: ( LV6,RANK:80 )
在线值:
发帖
回帖
粉丝
5
2天前
0
雪    币: 6453
活跃值: (8403)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
6
lwip  能移植到vt host里面不。
2天前
0
雪    币: 3315
活跃值: (2887)
能力值: ( LV6,RANK:90 )
在线值:
发帖
回帖
粉丝
7
木志本柯 lwip 能移植到vt host里面不。
建议直接移植freertos到host中,vmm作为freertos的一个高优先级任务处理vm事务,而lwip作为freertos另一个任务跑,以此实现host的独立网络通信,不过网卡驱动有的写了
2天前
0
雪    币: 6453
活跃值: (8403)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
8
TurkeybraNC 建议直接移植freertos到host中,vmm作为freertos的一个高优先级任务处理vm事务,而lwip作为freertos另一个任务跑,以此实现host的独立网络通信,不过网卡驱动有的写了
lwip只是tcp/ip协议栈,还需要编写一个下层的网卡驱动是吧?
2天前
0
雪    币: 3315
活跃值: (2887)
能力值: ( LV6,RANK:90 )
在线值:
发帖
回帖
粉丝
9
木志本柯 lwip只是tcp/ip协议栈,还需要编写一个下层的网卡驱动是吧?
对,需要完成lwip的ethernetif,也就是网卡驱动
1天前
0
雪    币: 4012
活跃值: (4431)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
10
好想法
1天前
0
游客
登录 | 注册 方可回帖
返回