arm64 linux内核中memcpy是如何运作的?
起因:做某个内核漏洞利用时,需要控制 copy_from_user() 的返回值为类似 0x7e 这种数,于是想当然构造了如下用户态代码
1 | char* buf = mmap(0x60000000, 0x1000, PROT_READ | PROT_WRITE, MAP_FIXED | MAP_SHARED | MAP_ANONYMOUS, -1, 0); |
按理说,内核态会有0x7e个字节拷贝失败,copy_from_user()的返回值应当为0x7e。然而,调试发现copy_from_user()的返回值为0xae(0xae-0x7e=0x30
)。试了0x63,返回值是0x93(0x93-0x63=0x30
),试了0x23,返回值是0x23(符合预期)。
很奇怪的现象,为什么返回值会出现跟预期不一样的情况?copy_from_user()底层实际拷贝时究竟是怎么做的呢?
涉及到一段汇编代码:copy_template.S
以arm64架构、linux 5.4.50 为例,探索下内核中的拷贝过程是怎样的。以大于128字节的情况为例,主要的拷贝逻辑在 Lcpy_body_large
中。
1 | /* SPDX-License-Identifier: GPL-2.0-only */ |
Lcpy_body_large 中主要逻辑如下图示。64字节为一组,上一轮中,已将源地址(用户态)64字节内容分别加载到A(A_l/A_h)B(B_l/B_h)C(C_l/C_h)D(D_l/D_h)对应的8个寄存器中。此轮中:
- 先将A存到目的地址(内核态)
- 然后从下一个分组读取16个字节到A寄存器(此时完成16字节从源地址到目的地址的写入)
- 后面的 3 4 5 6 7 8 依次重复 1 2的操作,完成共64字节的拷贝
理解汇编后,就能明白开头那段代码中为什么内核在 copy_from_user() 时返回值会出现 0x93(0x63+0x30)
和 0x23(0x23+0)
这两种情况。
第一种情况:本质是需要访问未映射页面的size大于等于0x40
假设左侧是已映射区域,右侧是未映射区域。那么当执行 2 时,内核访问未映射区域会进入错误页处理。此时存在寄存器B C D中的内容还未来的及写入目的地址中。于是未拷贝的长度实际是右侧未映射区域的大小(0x63),加上左侧B C D 寄存器中未写入的内容大小(0x30)。所以 copy_from_user() 返回未拷贝的长度是0x93。
第二种情况:本质是需要访问未映射页面的size小于0x40
剩余count小于0x40的情况下,不会再进入前面的循环拷贝过程,而是一次性将 A B C D 的内容写入目标地址,然后处理剩下的小于0x40的部分。访问这部分内容必然触发异常,于是未拷贝的长度就是未映射页面的size。
第三种情况:左侧已映射部分不是0x40的整数倍
前两种情况都是基于左侧已映射部分是0x40的整数倍为基础讨论的,假设左侧已映射部分是0x1b0,右侧是0x10。那么当执行到第8步时访问到非法内存,此时2 4 6步存到寄存器中的0x30字节内容未写入目的地址中,所以最终copy_from_user() 的返回值是 0x10+0x30=0x40。其他情况不枚举了。
总之,copy_from_user() 函数出现返回值跟预期不一致的原因,是因为实际拷贝操作中,异常发生时,寄存器内容未来得及写入目标地址。寄存器中未写入目标地址的内容也会被纳入未拷贝长度中,这是用户态不容易感知到的部分。
参考: