Hook Heaps and Live Free(译文)


在本文中,我们将与读者一起共同探讨Cobalt Strike、函数挂钩技术和Windows堆的相关问题。最后,我们将演示如何绕过BeaconEye(https://github.com/CCob/BeaconEye)的检测。

0x00 概述

最近,我从MDSec实验室看到了很多关于NightHawk及其神奇功能的推文。我受到启发,试图在我自己的dropper中实现这些功能,之所以这么做,一方面是为了更好地理解它,同时,也是为自己的红队工具箱添加一件更具竞争力的dropper。为此,我决定从加密堆分配开始下手。

让我们来谈谈为什么要对堆分配进行加密。首先,我们来简单介绍一下栈和堆之间的区别。栈是属于局部作用域的,当函数完成时,栈通常会超出该作用域。这意味着在函数运行期间保存在栈上的内容,在函数返回并完成时从栈中“脱落”;所以,对于想在内存中长期保留的变量来说,这显然不是个好去处。这时候,堆就可以派上用场了。大体来说,堆更像是一个内存长期存储的解决方案。堆上的分配空间会一直留在堆上,直到您手动释放它们为止。不过,如果您不断地将数据分配到堆上而没有释放任何内容,就会导致内存泄漏。

在进行上面的简单介绍之后,让我们看看堆中都是存放了哪些数据。堆有可能包含长期的配置信息,如Cobalt Strike的sacrificial进程、睡眠时间、回调路径等。了解到这一点,我们很显然希望保护这些数据。但是等等,这里好像并没有真正加密堆字符串。这意味着如果Cobalt Strike代理在内存中运行,任何防御方都可以看到它在进程堆空间中的纯文本配置。作为防御者,甚至不需要识别Cobalt Strike注入的线程;因为防御方可以直接通过HeapWalk() (https://docs.microsoft.com/en-us/windows/win32/api/heapapi/nf-heapapi-heapwalk)遍历所有分配的堆内存,并识别一些简单的东西,如"%windir%",以尝试识别Cobalt Strike被注入到哪个线程(显然,这是可以改变的,所以它并不是一个硬指标,但您至少得到了大致的想法。)下面,我们给出示例代码。

static PROCESS_HEAP_ENTRY entry;
BOOL IdentifyStringInHeap() {
    SecureZeroMemory(&entry, sizeof(entry));
    while (HeapWalk(GetProcessHeap(), &entry)) {
        if ((entry.wFlags & PROCESS_HEAP_ENTRY_BUSY) != 0) {
            // Find str in the allocated space by iterating over its whole size
            // lpData is the pointer and cbData is the size
            findStr("%windir%", entry.lpData, entry.cbData);
        }
    }
}

正如你所看到的,这是一个相当令人担忧的想法。因此,既然我们知道了这个问题,我们现在必须大胆地解决它。这就引出了一个问题:具体该怎么做?

我们有几个潜在的解决方案,以及每个解决方案所面临的问题。让我们从独立的EXE的情况开始,因为这个问题要简单得多。这个二进制文件是Cobalt Strike的payload。在这种情况下,我们可以非常容易地实现目标,因为唯一用到堆的代码,就是恶意的payload。使用前面提到的HeapWalk()函数,我们可以遍历堆中的每一个分配空间,并对其进行加密。为了防止出错,我们可以在加密堆之前挂起所有线程,然后在加密后再恢复所有线程的运行。

一个重要的注意事项:即使您认为自己的程序是单线程的,Windows在后台也会提供额外的线程,这些线程为RPC和Wininet等实用程序执行垃圾收集和其他类型的功能。如果您不挂起这些线程,当它们试图引用加密后的分配空间时,会导致进程崩溃。下面是一个崩溃示例:

1.png
Windows的后台线程

1.png
wininet.dll线程崩溃

从理论上讲,这是一个简单的实现! 最后,我们要解决的事情是如何在Cobalt Strike休眠时调用这一切。其实,解决办法非常简单。

ox01 Hooking技术

如果我们看一下Cobalt Strike二进制文件的IAT(导入地址表),我们会发现它是利用Kernel32.dll的Sleep函数来实现其休眠功能的。

1.png

Cobalt Strike的导入函数(我们最感兴趣的是Sleep函数)

接下来,我们需要做的就是在kernel32.dll中钩住Sleep函数,然后将我们钩住的Sleep函数中的行为做如下改动:

void WINAPI HookedSleep(DWORD dwMiliseconds) {
        DoSuspendThreads(GetCurrentProcessId(), GetCurrentThreadId());
        HeapEncryptDecrypt();
        OldSleep(dwMiliseconds);
        HeapEncryptDecrypt();
        DoResumeThreads(GetCurrentProcessId(), GetCurrentThreadId());
}

简单来说,我们要挂起所有线程并运行自己的加密例程,具体如下所示:

static PROCESS_HEAP_ENTRY entry;
VOID HeapEncryptDecrypt() {
    SecureZeroMemory(&entry, sizeof(entry));
    while (HeapWalk(currentHeap, &entry)) {
        if ((entry.wFlags & PROCESS_HEAP_ENTRY_BUSY) != 0) {
            XORFunction(key, keySize, (char*)(entry.lpData), entry.cbData);
        }
    }
}

这将创建一个PROCESS_HEAP_ENTRY结构体,并且每次调用都会将其清零,然后遍历堆并将数据放入该结构体中。然后,检查当前堆条目的标志,并验证它是否已经被分配,这样我们就能只在分配时进行加密。

然后,我们运行原始来的休眠函数(该函数将被创建为钩子函数的一部分),并在恢复线程之前进行解密。这样我们就可以防止在再次引用分配例程的时候出现崩溃。总的来说,这是一个相当简单的过程。我们还没有介绍钩子技术的威力。

首先,什么是函数挂钩技术呢?函数挂钩意味着我们在进程空间内重新路由对一个函数的调用,如Sleep(),以在内存中运行我们的任意函数。通过这样做,我们可以改变函数的行为,观察被调用的参数(因为我们指定的函数现在被调用了,所以,可以打印传递给它的参数,等等),甚至可以阻止该函数工作。在许多情况下,这就是EDR如何监测和警告可疑行为的工作方式。他们钩住被认为可疑的函数,比如CreateRemoteThread,并记录所有的参数,以便以后对可疑的调用发出警报。

让我们来看看如何钩住一个函数。实际上,有很多方法可以实现这一点,但我只打算介绍两钟技术,并只深入探讨其中一个。这里要介绍的两种技术是IAT挂钩技术(IAT hooking)和蹦床补丁(Trampoline Patching)技术。

0x02 IAT挂钩技术

IAT挂钩技术的思路很简单。每个进程空间都有一个所谓的导入地址表。这个表包含了一组DLL,以及二进制文件导入的函数的指针。我们推荐的、也是最稳定的挂钩方式,是浏览导入地址表,先找到要钩取的DLL,再找到要钩取的函数,然后,覆盖其函数指针,使其指向挂钩函数(hooked function)。每当进程调用该函数时,它就会找到该指针并调用你的函数。如果要将原函数作为挂钩函数的一部分进行调用,则可以存储原指针。这方面的示例代码可以在ired.team网站找到:https://www.ired.team/offensive-security/code-injection-process-injection/import-adress-table-iat-hooking

不过,这种方法有优点也有缺点。两个主要的明显的优点是,它的实现非常简单,而且非常稳定。毕竟,你只是改变了函数的调用,仅此而已,你并没有改变任何可能导致系统崩溃的东西。下面,我们来谈谈缺点。

如果代码使用GetProcAddress()来解析函数的话,它就不会出现在IAT中(虽然我相信你可以通过EAT钩子来解决这个问题,但这是另一个问题)。这是一种非常有针对性的挂钩方法,虽然有优点,但如果你想监控更广泛的调用,这就是一把双刃剑(比如可以挂钩NtCreateThreadEx,而不仅仅是CreateRemoteThread;但是如果调用的级别较低,可能会错过很多调用)。理论上来说,这也更容易被发现。

这很简单,我就不多说了。这里还有一篇讨论这个问题的文章:https://guidedhacking.com/threads/how-to-hook-import-address-table-iat-hooking.13555/

0x03 蹦床补丁技术

现在让我们来谈谈蹦床补丁技术。该技术更难实现,更难获得稳定性,而且由于必须解决很多相对的寻址问题,所以,在x64下需要很长的时间才能得到普及。值得庆幸的是,已经有人花时间做了一个开源库,以非常稳定的方式完成了所有这些所需的工作:https://github.com/TsudaKageyu/minhook

但是为了学习,让我们继续考察这种挂钩技术到底是如何工作的,如果愿意的话,大家也可以重新实现自己的钩子。起初,我曾考虑过分享我自己的实现,但我最终认为还是留给读者自己练手较好。相反,我们将对我的实现进行调试,以更好地理解这种补丁机制是如何工作的。

整个想法是这样的:我们将使用GetProcAddress和LoadLibrary解析函数的基址。然后,我们将解析有效汇编的前X个指令,并将其加到至少要有5个字节。更具体地说,我们将使用一种非常常见的技术,即使用五字节相对跳转操作码(E9)跳转到从函数基址+-2GB的位置,然后跳转到任意函数。显然,为了使其工作,我们需要覆盖函数的前五个字节。如果我们这样做,并且如果需要再次调用它,就会破坏原来的函数。为了确保可以在需要时解析原函数,我们必须保存第一条指令,该指令稍后将作为蹦床的一部分写入代码洞,而蹦床将为我们运行该指令,然后跳回函数下一条指令。但是,如果第一条指令只有四个字节,那么如果我们写五个字节的话,显然就会破坏第二条指令的第一个操作码。因此,我们需要将前两条指令都存储在蹦床中,这样的话,蹦床将运行前两条指令并跳回第三条指令继续执行。无论这个蹦床在哪里,都将成为被挂钩的原始函数的新指针。所以,原来的函数指针现在是这样的:

OldFunction = Trampoline -> JMP to original location of function + size of trampoline

这个代码洞还能跳转到我们任意函数的位置:在原函数基址处写入的、跳到相对基址五字节处的jump指令,将会跳到这里,然后跳转到任意函数,具体如下所示:

Base of old function jmps -> cave that contains the following assembly 
FF 25 00 00 00 00 [PUSH QWORD PTR]
00 00 00 00 00 00 00 00 [This is an arbitrary pointer to your function, in your C it would be &ArbitraryFunction]

这样,我们就可以在调用原函数时运行任意函数,并根据需要调用原函数。

现在,让我们通过调试来考察这一过程。这里,我们将挂钩MessageBoxa函数。首先,让我们看看MessageBoxA是“干净的”,还是已经被挂钩了。

首先,我们钩住MessageBoxa,具体代码如下所示:

Hook("user32.dll", "MessageBoxA", (LPVOID)NewMessageBoxA, (FARPROC*)&OldMessageBoxA);

函数MessageBoxA位于user32.dll中,因此,如果我们想要获得它的基地址,就必须从那里去找。为此,我们需要找到基地址,修补相应代码,向代码洞添加一些代码,解析相对跳转指令,并将蹦床存储在OldMessageBoxA函数中。

现在,已经挂钩的MessageBoxA函数如下所示:

int WINAPI HookedMB(HWND hWnd, LPCSTR lpText, LPCSTR lpCaption, UINT uType) {
    return OldMB(hWnd, "HOOKED", lpCaption, uType);
}

我们需要匹配返回类型和参数,这里我们将运行原始的MessageBoxA,但无论如何,我们至少要改一下文本,以最终显示“hooked”。

现在,让我们看看修改前后的样子。

修改前:

1.png

1.png

未挂钩的消息框函数打补丁之前显示的内容

修改之后:

1.png

1.png

对已经挂钩的消息框函数打补丁之后显示的内容

因此,消息框A是前面提到的问题的一个很好的例子。正如您所看到的,在修改之前的截图中,第一条指令只有四个字节。这意味着我们需要存储前两个指令;然后我们的相对跳转指令继续覆盖前五个。我们不需要更改剩余的字节,因为我们将让蹦床执行我们存储的前两个字节,然后跳回位置0x00007FF8EF70AC27处。现在,让我们继续在调试器中查看新的挂钩函数是什么样子的:

1.png

跳转到已挂钩的函数

这里我们先看到两个00。我这样做,是为了确保向代码洞中写入多个蹦床时,不会覆盖函数指针尾部的0000。接下来,我们看到FF2500000000,这是JMP QWORD PTR指令。之后,您将看到八个字节,它们是指向已挂钩的函数的指针!如果执行此指令,我们将看到:

1.png

被挂钩的函数中的第一条指令

最后:

1.png

被挂钩的函数内部情况

在这里,我们可以看到被挂钩的函数。这个函数一旦运行,就会立即返回原函数,因此让我们继续执行原函数:

1.png

调用原函数

让我们看看这会导致什么:

1.png

蹦床

如果你看一下这张图,会发现这里正在执行我们覆盖的前两条指令!就在复制的字节之后,我们执行了第二个JMP QWORD PTR指令,以便跳转到OriginalFunction+7处(因为这个例子中蹦床的大小是7个字节)。这将使我们处于第三条指令的起点位置。让我们继续看看。

1.png

继续执行

在这里,可以看到我们现在是在CMP指令处,从我们离开的地方继续执行。

通过这个过程,可以弄清楚像minhook这样的工具是如何工作的。现在,您既可以自己实现它,也可以直接使用像minhook这样稳定的东西。如果你觉得自己很喜欢冒险,我可以给你一些免费的、未经优化的代码洞搜索代码,你可以在此基础之上自己搞:

for (i = 0; i < 2147483652; i ++) { currentByte = (LPBYTE)funcAddress + i; if (memcmp(currentByte, "\x00", 1) == 0) { caveLength += 1; LPBYTE newByteForward = currentByte + 1; if (memcmp(newByteForward, "\x00", 1) == 0) { while (memcmp(newByteForward, "\x00", 1) == 0) { caveLength++; newByteForward++; } } if (caveLength >= totalSize) {
      while (memcmp(currentByte - 1, "\x00", 1) != 0 || memcmp(currentByte - 2, "\x00", 1) != 0) {
        currentByte++;
      }
      // Make sure the section is executable or try again
      MEMORY_BASIC_INFORMATION info;
      VirtualQuery(currentByte, &info, totalSize);
      if (info.AllocationProtect == 0x80 || info.AllocationProtect == 0x20 || info.AllocationProtect == 0x40) {
        break;
      }
      else {
        i += caveLength;
        caveLength = 0;
        continue;
      }
    }
    else {
      i += caveLength;
      caveLength = 0;
      continue;
    }
  }
}

0x04 将EXE放在一起

是时候把所有东西放在一起,看看效果如何了。下面给出具体步骤:

钩住Sleep()函数
在钩住的函数中,挂起所有的线程
使用HeapWalk()对所有分配的空间进行加密
通过蹦床函数运行原来的Sleep()函数
使用HeapWalk()解密所有分配的数据
恢复所有线程

我将假设你已经实现了自己的加密、挂钩和全线程暂停函数。这些代码大致是这样的:

static PROCESS_HEAP_ENTRY entry;
VOID HeapEncryptDecrypt() {
    SecureZeroMemory(&entry, sizeof(entry));
    while (HeapWalk(currentHeap, &entry)) {
        if ((entry.wFlags & PROCESS_HEAP_ENTRY_BUSY) != 0) {
            XORFunction(key, keySize, (char*)(entry.lpData), entry.cbData);
        }
    }
}
static void(WINAPI* OrigianlSleepFunction)(DWORD dwMiliseconds);
void WINAPI HookedSleepFunction(DWORD dwMiliseconds) {
    DoSuspendThreads(GetCurrentProcessId(), GetCurrentThreadId());
    HeapEncryptDecrypt();
    OriginalSleepFunction(dwMiliseconds);
    HeapEncryptDecrypt();
    DoResumeThreads(GetCurrentProcessId(), GetCurrentThreadId());
}

void main()
{
    DoSuspendThreads(GetCurrentProcessId(), GetCurrentThreadId());
    Hook("kernel32.dll", "Sleep", (LPVOID)HookedSleepFunction, (FARPROC*)&OriginalSleepFunction, true);
    if (!OldAlloc) {
        MessageBoxA(NULL, "Hooking RtlAllocateHeap failed.", "Status", NULL);
    }
    DoResumeThreads(GetCurrentProcessId(), GetCurrentThreadId());
    // Sleep is now hooked
}

上面的代码很简单,但是显然不包括implant代码。您可以通过执行shellcode在相同的进程空间中运行implant代码,也可以将其转换为DLL并将其注入到beacon中。由于它使用了HeapWalk()函数,所以,它可以加密过去、现在和将来分配的空间,而不会出现任何问题——只需要钩住Sleep()函数就可以开始调用。

演示时间到了!下面是具体的演示代码:

1.gif

EXE HeapWalk()加密器示例

如你所见,原先的休眠时间为1,这时BeaconEye捕捉到了我们的配置。然后,我们把睡眠休眠时间改为5,然后开始加密,成功关闭了BeaconEye。

请记住,由于这将加密所有堆分配,因此,它将不能作为注入线程工作,因为当Cobalt Strike处于休眠状态时,它所注入的进程将无法工作。想象一下,注入到Explorer.exe中,每次beacon休眠时,所有Explorer都会冻结。当需要作为线程注入时,这种解决方案显然不是最佳的方法。如果我们想让它作为一个线程工作,我们还有许多活要做。

在这里可以找到一个演示代码:https://github.com/waldo-irc/LockdExeDemo

0x05 针对特定线程的堆加密:注意事项

我们的新设计将不得不与一个独立的线程一起工作。我们将不能挂起其他线程;我们不能锁定堆,因为主进程需要继续运行。这意味着,当我们注入一个beacon线程时,我们必须确保所有的加密分配都只来自该线程。如果我们针对适当的线程,我们就可以成功地避免这个问题。那么,我们怎样才能做到这一点呢?

到目前为止,我们的dropper已经拥有钩子功能。为了操纵堆,需要用到Windows系统中的一个函数子集:

HeapCreate()
HeapAllocate()
HeapReAllocate()
HeapFree()
HeapDestroy()

Windows系统中的malloc和free函数位于msvcrt.dll库中,它们实际上就是HeapAllocate和HeapFree函数的高级包装器;而后两者又是RtlAllocateHeap和RtlFreeHeap函数的高级包装器,它们是Windows中最低级别的函数,直接管理堆。

1.png

来自Ghidra的截图

这意味着,如果我们将RtlAllocateHeap、RtlReAllocateHeap和RtlFreeHeap函数都挂钩,我们就可以跟踪Cobalt Strike中堆空间中分配和释放的所有内容。这当然是件好事,因为通过组合这三个函数,我们就可以在map中进行分配和重新分配,并在调用free时从map中删除它们。但是,这仍然无法解决我们的线程目标问题,不是吗?

不过,的确存在一个简单的解决办法!事实证明,如果从一个挂钩函数调用GetCurrentThreadId函数,实际上可以获得调用线程的线程id,通过它,就可以注入beacon,获得其线程id,并执行类似如下的操作:

GlobalThreadId = GetCurrentThreadId(); We get the thread Id of our dropper!
HookedHeapAlloc () {
    if (GlobalThreadId == GetCurrentThreadId()) { // If the calling ThreadId matches our initial thread id then continue
        // Insert allocation into a list
    }
}

这样做是为了重新分配内存,执行清除操作是为了释放内存,别忘了:现在目标是一个线程! 到目前为止,事情看上去很简单。但还记得之前的那个问题,我们不得不挂起其他线程的原因吗?在我们及时解密之前,WININET和RPC调用仍然会试图访问加密的内存。这里有几个选项,但我使用了自认为很有趣的一个。由于加载的shellcode既不是有效的EXE也不是DLL,因此,我能够从任何发起调用的对象中分配内存,这些调用源自没有名称的模块。

为了让这个机制起作用,我们需要解析进行函数调用的模块。为此,这可以通过以下代码:

#include 
#pragma intrinsic(_ReturnAddress)
GlobalThreadId = GetCurrentThreadId(); We get the thread Id of our dropper!
HookedHeapAlloc (Arg1, Arg2, Arg3) {
    LPVOID pointerToEncrypt = OldHeapAlloc(Arg1, Arg2, Arg3);
    if (GlobalThreadId == GetCurrentThreadId()) { // If the calling ThreadId matches our initial thread id then continue

      HMODULE hModule;
      char lpBaseName[256];
    if (::GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, (LPCSTR)_ReturnAddress(), &hModule) == 1) {
           ::GetModuleBaseNameA(GetCurrentProcess(), hModule, lpBaseName, sizeof(lpBaseName));
         }
        std::string modName = lpBaseName;
        std::transform(modName.begin(), modName.end(), modName.begin(),
                [](unsigned char c) { return std::tolower(c); });
        if (modName.find("dll") == std::string::npos && modName.find("exe") == std::string::npos) {
                     // Insert pointerToEncrypt variable into a list
        }
    }
}

上面的代码用于获取intrinsic函数_ReturnAddress,并利用它与GetModuleHandleEx和GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS标志来识别是哪个模块发起的这个调用。然后,我们可以把它转换成小写的字符串,如果这个字符串不包含DLL或EXE,我们将其插入。有了这个,就获得了一个稳定的分配列表,就可以在休眠时进行加密了。不过,你将需要为挂钩的重新分配函数重复这个过程。

为了执行加密操作,需要遍历这个列表并加密这些分配的内存,但是不能用HeapWalk()函数。这将取决于你是否决定使用map、向量、链接列表或其他数据结构。你可能想把真正的HeapAlloc或ReAlloc返回的指针存储到数组中,遍历数组并按大小对那里的数据进行加密。上面的例子中的Arg3就是存放的内存大小数据(https://docs.microsoft.com/en-us/windows/win32/api/heapapi/nf-heapapi-heapalloc)。

所以,现在我们可以钩住四个不同的函数,根据线程ID将分配的内存地址放到一个向量中,然后遍历向量并在休眠时加密每个地址。如果成功,我们应该能够再次绕过BeaconEye。

现在是演示时间! 同样,为了演示的目的,我们不会让休眠时间为1或更少。

1.gif

注入到cmd.exe中并绕过BeaconEye

1.png

注入到explorer.exe中,并且运行稳定

成功了! 实际上,我们可以注入到任何进程,并且只加密我们自己线程的堆;该进程不会因为我们的代码进入休眠而崩溃。

0x06 其他发现

在实现稳定的堆加密的旅程中,我还发现了三个有趣的东西,下面分别加以介绍。

前两个发现,实际上就是其他的BeaconEye绕过方法。与任何工具一样,BeaconEye也有自己的缺陷。在无意之中,我发现了两钟彻底绕过BeaconEye的机制。

第一个机制是用beacon注入explorer.exe,似乎可以完全绕过BeaconEye,具体如下所示。

1.gif

基于Explorer.exe的BeaconEye绕过方法

正如你所看到的,对cmd.exe执行注入操作时的确被发现了,但Explorer.exe似乎没有得到有效的检查。

此外,通过初始化二进制文件中的相关符号,也能顺利绕过BeaconEye,具体代码如下所示:

#include 
#pragma comment(lib, "dbghelp.lib")
SymInitialize(GetCurrentProcess(), NULL, TRUE);

请看下面的演示:

1.gif

用符号绕过BeaconEye

最后,我注意到一些有趣的事情……我不确定其他人是否已经知道,但Cobalt Strike在退出时绝对不会清理在堆中分配的内存。这意味着如果你退出一个注入的Cobalt Strike线程后,并且进程没有重启,相关配置就会作为一个可提取的证据留在内存中。

最终演示:

1.png

堆中的证据

也许利用本文中所介绍的知识,读者自己就可以设法解决这个问题。

1.gif

清理堆

0x09 小结

鉴于本人水平有限,本文中难免会出错,欢迎大家请随时指出,我很乐意改正。毕竟学习交流才是本文的主要目标。

文中的演示代码,可以从这里下载:https://github.com/waldo-irc/LockdExeDemo

在我的下一篇文章中,我想研究一下钩子技术的其他用途,也许它们可以用来做一些更有趣的事情。

原文地址:https://www.cyberark.com/resources/threat-research-blog/hook-heaps-and-live-free

评论

Q

qing16

这个人很懒,没有留下任何介绍

twitter weibo github wechat

随机分类

Exploit 文章:40 篇
APT 文章:6 篇
memcache安全 文章:1 篇
CTF 文章:62 篇
网络协议 文章:18 篇

扫码关注公众号

WeChat Offical Account QRCode

最新评论

Article_kelp

因为这里的静态目录访功能应该理解为绑定在static路径下的内置路由,你需要用s

N

Nas

师傅您好!_static_url_path那 flag在当前目录下 通过原型链污

Z

zhangy

你好,为什么我也是用windows2016和win10,但是流量是smb3,加密

K

k0uaz

foniw师傅提到的setfge当在类的字段名成是age时不会自动调用。因为获取

Yukong

🐮皮

目录