Windows主机入侵检测与防御内核技术深入解析
2024-6-1 14:55:27 Author: mp.weixin.qq.com(查看原文) 阅读量:7 收藏

第4章防御方案的设计与集成(上)

4.1 可疑库的设计

本节将介绍可疑库的设计,以及我们在Windows中,是如何拦截模块的加载、执行,并在其中插入路径判断的。

4.1.1 可疑库的数据结构设计

可疑库实际上是可疑路径库,是一组路径的集合。每当Windows中有模块要被加载执行的时候,我们应该将被加载执行的路径和可疑库中的路径进行比较。如果前者在可疑库中,说明该文件可疑,不应直接执行,而应进一步处理。反之,则该模块可以直接执行。

这样做的本质理由是为了避免每次都进行相当消耗性能的处理,比如计算整个文件内容的散列值。所以这个比较应该非常快,不应损耗太多性能。作为反面典型的设计方法是将所有的路径保存在一个数组中,每次需要对比的时候将目标路径和库中所有路径挨个对比。这样当库中路径非常多的时候,其性能损耗可能会不亚于计算文件的散列值。

有很多方法可以提高字符串对比的性能。本书的代码使用哈希表。即将所有可以库中的路径计算一个散列值(在这里又常称为哈希值),然后插入哈希表中。哈希表由一定数量的哈希链(每个都是单链表)组成。哈希链的头保存在一个由散列值索引的数组中,这样查找很快。当两个路径的散列值一样的时候,它们会被插入同一条哈希链上。

在对比任何一条路径的时候,可先计算该路径的散列值,然后瞬间定位(通过数组索引而无需对比)到该散列值对应的哈希链上。遍历哈希链逐个进行字符串的对比,即可确认该路径是否在可疑库中。只要确保哈希链的条数不会远小于路径库中路径的总数,也就是每条哈希链上的路径数不会太多,性能就是可靠的。

当然读者也可以用任何可以进行高性能对比查找的数据结构来实现可疑库,比如各种树。

在实际项目中,哈希表中的散列算法(这里又可称哈希算法)的选择非常重要。算法必须计算快捷、散布均匀,才能充分地利用处理器资源和存储空间。但这对本书来说不是重点。所以我只很简单地对路径上每个字符求和来获得一个散列值。

这里有一个特别要注意的地方:Windows的文件路径并不区分大小写。所以在对比和计算散列值之前,都必须将文件路径全部转成大写或者小写,本书中一律转成大写。

Windows内核中,字符串一般用UNICODE_STRING结构来表示。考虑到本书中的路径字符串将会插入到链表中,因此需要重新包装,在其后增加一个指针以便插入链表。因此,本书代码中的可疑库中的可疑路径数据结构定义如代码4-1所示。要注意其中的字符串成员path必须是已经全部转换为大写的,否则计算散列值和对比都会出麻烦。

       代码4-1 可疑库中的可疑路径数据结构定义

// 在可疑路径哈希表中,我在每个哈希行中保存一个单链表
typedef struct DUBIOUS_PATH_ {
     UNICODE_STRING path;
     struct DUBIOUS_PATH_* next;
} DUBIOUS_PATH;

因为成员pathUNICODE_STRING类型,而UNICODE_STRINGWindows内核定义的,其结构如代码4-2所示。其中Length表示的是实际字符串的字节(尤其是要注意是字节,而不是字符数)长度。这个长度不含字符串末尾的NULL结束符,实际上这种字符串末尾也不一定有结束符。而MaximumLength表示的是缓冲区指针Buffer指向的空间可用的实际长度。

       代码4-2 Windows内核定义的UNICODE_STRING结构

typedef struct _UNICODE_STRING {
    USHORT Length;
    USHORT MaximumLength;
    PWCH   Buffer;
} UNICODE_STRING;

指向真正的字符串内容的指针Buffer显然必须指向一块有效的内存。为了简便起见,本书中的代码都会将结构DUBIOUS_PATH的内存空间分配得更大,让path.Buffer的内容刚好指向DUBIOUS_PATH后部的“多余”区域。所以DUBIOUS_PATH的真正结构如图4-1所示。

图4-1 DUBIOUS_PATH的真正结构

此外,本书中实际的路径散列值计算代码如图代码4-3所示。这些代码唯一目的是简单并用于理解。但实际商用中可能需要选择性能更好、散布更均匀的哈希算法。

       代码4-3 路径散列值计算代码

// 路径哈希表的最大值
#define HASH_MAX 0xffff
// 出于代码简单起见,我仅仅将路径上所有字符求和来计算一个0-0xffff范围内的
// 散列值
USHORT DubiousPathHash(PUNICODE_STRING mod_path)
{
     ULONG wchar_sum = 0;
     int i;
     ULONG wchar_cur;
     for (i = 0; i < mod_path->Length / sizeof(WCHAR); ++i)
     {
         wchar_cur = mod_path->Buffer[i];
         wchar_sum += wchar_cur;
     }
    return (USHORT)(wchar_sum & HASH_MAX);
}

该代码的算法并未考虑简单求和获得的散列是否均匀。但可以看出,散列值最大为0xffff,实际一共可以拥有65536个散列值。也就是说,如果可疑路径库中的可疑路径不远大于这个数字,性能损耗就不会太严重。如果担心性能不足,则还可增大这个值,只是会增加少量内存损耗。

可疑路径的数量实际上会达到多少,这是很难预计的。最好的办法是将实际用户的可疑库中的路径数量作为日志上报上来,后续按实际的值来调整哈希表的大小以便性能达到最优。

最后,作为可疑库(实际是一个哈希表)的实体,我们将定义一个静态全局数组。它的每个数组元素指向一个哈希链。所以毫无疑问,这个数组的元素个数应该为代码4-3中的HASH_MAX1。注意这个加1!代表可疑库的全局变量g_dubious_path定义如代码4-4所示。

代码4-4 代表可疑库的全局变量g_dubious_path定义

// 哈希表,保存所有可疑路径。注意如果这里HASH_MAX
// 忘记加1,会导致一个典型的内核越界漏洞,可能导致崩溃并可能被外界利用
static DUBIOUS_PATH* g_dubious_path[HASH_MAX + 1] = { NULL };  (1)
// 操作哈希表用的锁。
static KSPIN_LOCK g_dubious_lock; (2)

假定上面代码的(1)处的定义缺失了“+1”,那么这个数组将只有0xffff个元素。由于元素下标从0开始,所以最大下标为0xfffe。那么攻击者只要尝试生成一个路径计算散列值为0xffff的可执行文件,我们的系统就会往内核中越过数组边界写入一个指针。

这种写入有可能是无大碍的(若数组之后是一段没什么用的多余空间),但也有可能是致命的。若假定后续是一个重要结构指针,被覆盖后可能导致系统蓝屏。此外还可能引入安全漏洞,如果该处涉及一些重要的安全策略。

从这里我们可以看到,由安全系统引入新的安全问题,这看起来似乎很讽刺,其实是完全有可能的。

除了全局变量g_dubious_path之外,(2)处的自旋锁用来保持可疑库的操作的同步性,防止多线程竞争的情况下搞坏链表。

4.1.2 可疑库的查找

可疑库在实际应用中,首要作用是判断某个路径是否可疑。该操作即是输入一个文件的全路径,判断该路径是否在可疑库中,这是一个典型的查找工作。本例中实现的判断路径是否可疑的代码如代码4-5所示。

代码4-5 本例中实现的判断路径是否可疑的代码

BOOLEAN IsDubious(PUNICODE_STRING mod_path)
{
     KIRQL irql = PASSIVE_LEVEL;
     BOOLEAN locked = FALSE;
     BOOLEAN ret = FALSE;
     USHORT hash;
     do {
         // 在安全的中断级调用
         ASSERT(KeGetCurrentIrql() <= APC_LEVEL);
         BreakIf(KeGetCurrentIrql() > APC_LEVEL); (1)
         // 检查参数并计算哈希值,然后加锁
         BreakIf(mod_path == NULL);
         // 注意如果路径过长,直接判断可疑,禁止任何超长路径的可执行模块加载。
         BreakDoIf(mod_path->Length >= DUBIOUS_MAX_PATH, ret = TRUE);  (2)
        hash = DubiousPathHash(mod_path);  (3)
          KeAcquireSpinLock(&g_dubious_lock, &irql); (5)
         locked = TRUE;
         // 对比所有的节点来寻找
      BreakDoIf(DubiousSearchInHashLine(g_dubious_path[hash], mod_path),
              ret = TRUE); (6)
     } while (0);
     DoIf(locked, KeReleaseSpinLock(&g_dubious_lock, irql));
     if (ret)
     {
         LOG(("KRPS: IsDubious: %wZ is dubious.\r\n", mod_path));  (7)
     }
     else
     {
         // 正常情况下,我们对不可疑的模块不显示,但需要时也可以显示一下
         // LOG(("KRPS: IsDubious: %wZ is not dubious.", mod_path));
     }
     return ret;
}

输入参数mod_path就是某个模块的全路径,它必须是全大写的。接下来的代码和我们前面看到过的各种处理代码相似,都是先检查中断级。如果中断级不正确则直接返回,不做任何处理。如(1)处代码所示。

接下来检查输入参数。mod_path显然不应为NULL。如果为NULL则不做处理。但为了确保安全期间,这里在(2)处也检查了一下mod_path的长度。

微软的NTFS文件系统本身并不限制文件路径的长度。但是无限长的文件路径很容易带来问题。如内存分配失败、字符串比较导致越界等等。考虑到长度超过一定限度的路径本身就已经很可疑(可能是攻击者的一种试探),这里直接简单地将所有超长路径设定为可疑。所以处设置了(2)ret = TRUE,表示返回路径可疑。

然后是(3)处调用DubiousPathHash计算散列值。其实现见代码4-3。得到散列值之后,在(5)处用自旋锁进行加锁。

之所以这里要加锁,是因为Windows中可执行模块的修改和加载显然可能在任何进程中随时随地地发生。如果两个线程在两个核上同时进行可疑库的查询和増删,就会带来一堆冲突问题。所以这里用Windows内核中最常用的自旋锁进行加锁。

使用自旋锁需要注意两点:

1)自旋锁是一种轮询机制实现等待的锁,处理器性能损耗很大。所以不要在获得锁之后做太长或者太复杂的处理。否则其他线程等待锁的时候会耗费太多处理器时间。

2)自旋锁会导致中断级升高。因此如果在获得锁之后调用任何系统提供内核函数,都要重新审视这些函数的中断级要求。我个人的经验是,最好不要试图在获得锁之后调用任何内核函数。

在获得了散列值((3)处得到的hash)之后,哈希表中的哈希链的指针就是g_dubious_path[hash]。这个索引查找过程极快,几乎没有性能损失。性能损失会出现在6处的DubiousSearchInHashLine中。

该函数对单链表进行查找,并逐一和输入参数字符串进行完全的对比,因此会比较损耗性能。但在每条哈希链上的节点数量很少的情况下,这个损失也是基本可以忽略不计的。

(7)处出现了LOG宏。该宏是我自定义的,作用和KdPrint类似。我们可以简单地用#define LOG KdPrint来实现它。自定义一个宏的好处是,可以随时用它实现更多的功能,比如写入日志文件等等。

 (6)处用到的DubiousSearchInHashLine的实现如代码4-6所示。

代码4-6 DubiousSearchInHashLine的实现

static BOOLEAN DubiousSearchInHashLine(
     DUBIOUS_PATH* hash_line, PUNICODE_STRING mod_path)
{
     BOOLEAN ret = FALSE;
     DUBIOUS_PATH* next = NULL;
     next = hash_line;
     while (next)
     {
         DUBIOUS_PATH_ASSERT(next);
         // 这里用大小写敏感比较。这是因为我之前已经做过了大写化。大
         // 小写敏感的不会浪费性能(虽然这浪费很小很小)
         if (RtlCompareUnicodeString(
              &next->path,
              mod_path,
              TRUE) == 0)
         {
              ret = TRUE;
              break;
         }
         next = next->next;
     }
     return ret;
}

可以看到其主要逻辑是遍历一个单向链表,并且使用Windows内核提供的字符串比较函数RtlCompareUnicodeString对字符串进行比对。

4.1.3 可疑路径的增加

你可能已经注意到,可疑库类似在内核中实现的一个临时数据库。整个系统对它主要的操作将会是增加一条可疑路径,或者将一条可疑路径从其中删除。本节的代码即是从4.1.1节中所述的哈希表中增加和删除数据的实现。

往哈希表中增加数据最需要注意的一点是:在增加一条数据之前,应先检查该数据是否已经在哈希表中。如果已经存在则不要添加,应该直接返回成功。重复添加数据可能会造成各种问题。

检查数据是否已经存在于哈希表中和通常的操作一样,都需要加锁来防止线程冲突。

有一种常见的错误流程是加锁->确认数据不在表中->解锁->加锁->添加数据->解锁。你会注意到这种操作的问题在确认数据不在表中和添加数据之间有一个解锁的过程。而这个过程有可能存在其他线程“趁机”插入数据的可能。为了避免这种情况的发生,确认数据不在表中和插入必须“一气呵成”,也就是说,在一次加锁-解锁的过程中完成,而不是分阶段的。

4.1.2节已经实现了函数DubiousSearchInHashLine,该函数可以搜索一个可疑路径是否存在于可疑库中,恰好可以被本节代码利用。增加可疑路径的完整的实现如代码4-7所示。

       代码4-7 增加可疑路径的完整的实现

void DubiousAppend(DUBIOUS_PATH* mod_path)
{
     DUBIOUS_PATH* hash_header = NULL;
     USHORT hash;
     KIRQL irql = PASSIVE_LEVEL;
     BOOLEAN locked = FALSE;
     DUBIOUS_PATH* next = NULL;
     DUBIOUS_PATH* to_release = NULL;
     do {
         // 此函数永远在安全的中断级调用
         ASSERT(KeGetCurrentIrql() <= APC_LEVEL);
         BreakIf(KeGetCurrentIrql() > APC_LEVEL);
         // 检查参数并计算哈希值,然后加锁
         BreakIf(mod_path == NULL);
         hash = DubiousPathHash(&mod_path->path);
          KeAcquireSpinLock(&g_dubious_lock, &irql);  (1)          
         locked = TRUE;
         // 在插入之前,先要遍历所有节点。对比是否存在重复的节点。如果有,
         // 那就直接释放掉即可
         next = g_dubious_path[hash];
         // 有效性检查,提前发现乱指针bug
         DUBIOUS_PATH_ASSERT(next);
       BreakDoIf(DubiousSearchInHashLine(next, &mod_path->path),
              to_release = mod_path);  (2)
         // 到这里可以真正添加了
         hash_header = g_dubious_path[hash];
         g_dubious_path[hash] = mod_path;  (3)
         mod_path->next = hash_header;
         LOG(("KRPS: DubiousAppend: %wZ is dubious.\r\n", &mod_path->path));
     } while (0);
     DoIf(locked, KeReleaseSpinLock(&g_dubious_lock, irql)); (4)
     DoIf(to_release != NULL, ExFreePool(to_release));   (5)
     return;
}

结合前面讲过的要注意的点,上述代码非常简单。自旋锁的加锁和解锁位于(1)处和(4)处,将判断路径是否已经在表中和真实插入表中的操作完整地囊括其中,而不是用两个各自加锁的函数孤立地进行,这杜绝了被其他线程横插一脚的可能。

注意(4)处位于do-while(0)循环之外,因此这里是函数返回的必经之路,必然被执行。所以这里用了一个局部变量locked做辅助判断,如果lockedTRUE表示已经加锁因此会释放锁。这也是本书代码的惯用形式。

DubiousSearchInHashLine的调用在(2)处进行。如果返回了TRUE,说明路径已经在可疑库中,这时候就break跳出do-while(0),避开了后面的处理。

这里要特别注意内存的分配与释放。正常情况下,函数DubiousAppend会把参数mod_path指针存入到哈希表中,而DubiousAppend的调用者虽然负责分配mod_path的内存,但并不负责释放。因此当该路径已经在可疑库中无须再次插入的时候,本函数得释放这块内存。(2)处的代码会把mod_path赋给to_release。而在(5)处,如果to_release不为NULL,则会用ExFreePool释放调这块内存。

(3)处实现了往哈希表中插入的操作。在已经求得散列值hash的前提下,g_dubious_path[hash]即是一个单链表的头部,将节点插入此链表即可。这里使用的是头部插入法,也就是将节点插入到链表的最前端。首先保存原始的链表头,然后将自身替代成链表头,而自身的next则设置为下一个节点而完成插入。

4.1.4 可疑路径的删除和移动

在可疑库中删除一个路径的操作和增加刚好是相反的。理论上,和增加的操作一样,也需要进行加锁。但是这里还需要考虑另一个常见的情况,就是可疑路径的移动。

在一个可疑文件被重命名的时候,它在可疑库中的路径也同样应该随之“重命名”。但这个重命名并不是简单地修改一个节点,而是应该重新计算其散列值,并将节点移动到哈希表上的另一个地方。此操作非常麻烦,还不如重用增加和删除的操作。也就是说,移动一个节点,等于先删除一个、然后再增加一个节点。

如果只是局限在这个需求下思考,我们很容易在这里埋下祸根,为将来来各种缺陷和漏洞。

因为先删除、后增加并非一个完整同步的操作。在4.1.3节中,我们已经将可疑路径的增加设计成了先加锁,完成增加,然后再解锁。那么如果在移动中调用此函数,就意味着调用之前必须解锁,否则就变成双重加锁。

正确的操作应该是删除-增加的操作放在一次加锁-解锁的区间中,而不应该将它们分开。一旦分开,中间解锁的窗口会发生什么就很难预计了。因此在移动中如果需要用到增加,不能直接调用代码4-7实现的函数DubiousAppend。但好在这个操作并不复杂,很容易自行实现。

因此,本节代码实现节点删除的时候,我们并不直接实现一个完整的删除函数,而是分两步来完成:先完成一个不加锁的只是从链表中移除节点的函数,再增加一个加锁的真正的删除节点的函数。这样前者在后面实现可疑路径的移动的时候还可疑再用到。从一个哈希链中移除节点的实现如代码4-8所示。

代码4-8 从一个哈希链中移除节点的实现

static DUBIOUS_PATH* DubiousRemoveFromHashLine(
     DUBIOUS_PATH** hash_line, PUNICODE_STRING mod_path)
{
     DUBIOUS_PATH* ret = NULL;
     DUBIOUS_PATH* previous = NULL;
     DUBIOUS_PATH* next = NULL;
     previous = *hash_line;
     next = *hash_line;
     while (next)
     {
         DUBIOUS_PATH_ASSERT(next);
         // 这里用大小写敏感比较。这是因为我之前已经做过了大写化。大
         // 小写敏感比较性能较好
         if (RtlCompareUnicodeString(
              &next->path,
              mod_path,
              TRUE) == 0)
         {
              // 如果找到了,这里要进行脱链。
              ASSERT(previous != NULL);
              // 如果找到的是第一个,又要移除,那么就必须修改哈希链上的
              // 第一个为NULL
              if (previous == next)
              {
                   *hash_line = NULL;
              }
              else
              {
                   previous->next = next->next;
              }
              ret = next;
              break;
         }
         previous = next;
         next = next->next;
     }
     return ret;
}

这段链表中删除节点的操作非常简单。从链表头开始用函数RtlCompareUnicodeString进行字符串比较,找到相同的字符串,即可移除节点。要注意这里的移除仅仅是将节点从链表中脱链并返回,并没有进行内存释放的操作。

有了该移除函数之后,真正的删除就可以简单地调用这个函数就行了。删除可疑路径的函数DubiousRemove的实现如代码4-9所示。

代码4-9 删除可疑路径的函数DubiousRemove的实现

// 清理并释放一个可疑路径
void DubiousRemove(PUNICODE_STRING path)
{
     DUBIOUS_PATH* hash_header = NULL;
     USHORT hash;
     KIRQL irql = PASSIVE_LEVEL;
     BOOLEAN locked = FALSE;
     DUBIOUS_PATH* to_release = NULL;
     DUBIOUS_PATH* next = NULL;
     do {
         // 只能在安全的中断级调用
         ASSERT(KeGetCurrentIrql() <= APC_LEVEL);
         BreakIf(KeGetCurrentIrql() > APC_LEVEL);   (1)
         // 检查参数并计算哈希值,然后加锁
         BreakIf(path == NULL);
         hash = DubiousPathHash(path);
          KeAcquireSpinLock(&g_dubious_lock, &irql); (2)
         locked = TRUE;
         // 把src找到并删除了
         to_release = DubiousRemoveFromHashLine(
              &g_dubious_path[hash],
              path);
     } while (0);
     DoIf(locked, KeReleaseSpinLock(&g_dubious_lock, irql)); (3)
     if (to_release != NULL)
     {
         ExFreePool(to_release); (4)
         LOG(("KRPS: DubiousRemove: %wZ removed.\r\n", path));
     }
}

该实现在DubiousRemoveFromHashLine的周边,包装了中断级的检查(1)、自旋锁加锁和解锁(2)(3)、以及节点移除之后内存的释放(4),因此变成了可以独立调用的完整的删除一个节点的操作。

但是在移动一个路径(也就是先删除,再增加)的时候,我们不能直接调用这个有加锁的函数,而应该使用DubiousRemoveFromHashLine来移除节点然后再自行释放内存。

因此,一个可疑文件被移动而改变路径的时候,在可疑库中“移动”可疑路径的函数DubiousMove的实现如代码4-10所示。

代码4-10 在可疑库中“移动”可疑路径的函数DubiousMove的实现

// 移动一个可疑路径。注意dst_path是个分配出来的DUBIOUS_PATH,本函数会负责
// 释放或者不释放(不释放就会插入哈希表中)。所以外部不需要再释放。
void DubiousMove(PUNICODE_STRING src_path, DUBIOUS_PATH* dst_path)
{
     DUBIOUS_PATH* hash_header = NULL;
     USHORT src_hash;
     KIRQL irql = PASSIVE_LEVEL;
     BOOLEAN locked = FALSE;
     DUBIOUS_PATH* to_release1 = NULL;
     DUBIOUS_PATH* to_release2 = NULL;
     DUBIOUS_PATH* next = NULL;
     USHORT dst_hash;
     do {
         // 在安全的中断级调用。
         ASSERT(KeGetCurrentIrql() <= APC_LEVEL);
         BreakIf(KeGetCurrentIrql() > APC_LEVEL);
         // 检查参数并计算哈希值,然后加锁。
         BreakIf(src_path == NULL || dst_path == NULL);
         DUBIOUS_PATH_ASSERT(dst_path);
         LOG(("KRPS: DubiousMove: %wZ mov to\r\n", src_path));
         LOG(("KRPS: DubiousMove: %wZ\r\n", &dst_path->path));
         src_hash = DubiousPathHash(src_path);
          KeAcquireSpinLock(&g_dubious_lock, &irql);
         locked = TRUE;
         // 先把src找到并删除了
         to_release1 = DubiousRemoveFromHashLine(     (1)
              &g_dubious_path[src_hash],
              src_path);
         // 然后把dst_path给追加进去。
         dst_hash = DubiousPathHash(&dst_path->path);  (2)
         next = g_dubious_path[dst_hash];
         DUBIOUS_PATH_ASSERT(next);
         // 如果dst已经存在,那么就释放dst_path,因为没有必要重复添加
       BreakDoIf(DubiousSearchInHashLine(next, &dst_path->path),
              to_release2 = dst_path);
         // 到这里可以真正添加了。
         hash_header = g_dubious_path[dst_hash];
         g_dubious_path[dst_hash] = dst_path;
         dst_path->next = hash_header;
     } while (0);
     DoIf(locked, KeReleaseSpinLock(&g_dubious_lock, irql));
     DoIf(to_release1, ExFreePool(to_release1));
     DoIf(to_release2, ExFreePool(to_release2));
}

DubiousMoveDubiousRemove一样,都是可疑库的操作接口之一,因此必须也和DubiousMove一样必须考虑中断级、多线程冲突等问题。其主要操作是在(1)处调用DubiousRemoveFromHashLine先移除旧的链表节点,然后在(2)处插入新节点。

注意这里不能调用4.1.3节中的DubiousAppend来替代2处的代码。原因是DubiousAppend也是加锁的。一旦在这里调用会造成重复加锁。把整个函数用DubiousRemoveDubiousAppend先后调用来替代也不可行。因为这两个函数会先后加锁和解锁,它们调用之间存在未加锁的空隙,可能导致问题。

看雪ID:星星人

https://bbs.kanxue.com/user-home-143652.htm

*本文为看雪论坛优秀文章,由 星星人 原创,转载请注明来自看雪社区

# 往期推荐

1、Windows主机入侵检测与防御内核技术深入解析

2、BFS Ekoparty 2022 Linux Kernel Exploitation Challenge

3、银狐样本分析

4、使用pysqlcipher3操作Windows微信数据库

5、XYCTF两道Unity IL2CPP题的出题思路与题解

球分享

球点赞

球在看

点击阅读原文查看更多


文章来源: https://mp.weixin.qq.com/s?__biz=MjM5NTc2MDYxMw==&mid=2458557570&idx=1&sn=4b3364491b79fa6e0c29391eb085a9d3&chksm=b18dac0886fa251e07b0e2c4c134e95157aed620b25141f57760d8eca269a65fceb2c9bcc0c1&scene=58&subscene=0#rd
如有侵权请联系:admin#unsafe.sh