调试Linux内核问题的时候,你有没有遇到过这种场面:一条printk打印出来的指针看起来是个正常的十六进制地址,但你想把它和/proc/kallsyms、crash工具里拿到的地址对一下,却发现完全对不上。好几年前我第一次碰到这种情况,以为是代码里拿错了指针,折腾了大半夜才发现,真正的原因是内核在默认情况下会对%p输出的指针做哈希处理,而关闭这个行为的关键参数就叫no_hash_pointers。这篇文章就围绕这个参数展开,从内核打印链路、参数设置方法、安全代价到替代方案,一次讲清楚。
无论你是写驱动的人、追内核crash的运维,还是对Linux安全机制好奇的读者,只要见过dmesg里那串“假地址”,这篇文章就能帮你省下不少排查时间。准备好就往下看,先把“指针是怎么变假的”这件事说明白。
1. 一次指针“对不上号”的排查,逼我认真读了一遍 vsprintf 源码
1.1 现象:printk里打印出来的地址,居然不是地址
先说说我当时遇到的问题。某个内核模块里有一个全局结构体,我在驱动初始化时用pr_info("my_struct = %p\n", &my_struct)打了日志,输出值是000000006f07d3b8。当时我想确认这个结构体在内存里的真实位置,就去查/proc/slabinfo,但里面显示的地址是类似ffff8880a1b2c3d4这种典型的内核动态映射地址。两个数字差的不是一点半点,一个是32位的短地址,一个是64位的完整地址。
一开始我怀疑是printk参数写错了,把指针的指针传进去了;又怀疑是结构体成员偏移算错。折腾了很久,最后干脆在代码里同时打印%p和%px,结果让我很意外:%p输出000000006f07d3b8,%px输出ffff8880a1b2c3d4。同一个变量,两种打印格式,输出完全不同,这说明问题不在我的代码,而是printk本身对%p做了手脚。
后来我把日志导到文本文件里对比了几轮,发现一个规律:同一轮启动过程中,同一个结构体的%p值始终不变;但是重启之后,同一个结构体的%p变成了另一个随机值。这说明%p的输出是“稳定但不可逆”的——它不像加密那样每次变化,而是给同一个指针固定分配了一个假ID,但这个假ID无法反推出真实地址。
1.2 是规范还是bug:内核为什么故意把地址“弄假”
搞明白不是自己的代码问题之后,我反而更好奇:内核为什么要把一个普普通通的指针打印弄成这样?答案和内核地址空间布局随机化(KASLR)有关。
KASLR的意思是,内核每次启动时,把内核镜像、模块、堆、栈等关键区域的加载地址随机化。这样一来,攻击者即使拿到了一个漏洞,想知道内核基址也不容易,因为每次重启地址都不一样。但日志是个明显的泄露渠道。/dev/kmsg、dmesg、/var/log/kern.log里如果打印了大量真实指针,攻击者只要拿到这些日志的一小段,就能算出KASLR偏移,然后精确地构造后续攻击。
所以内核在多年以前就开始对%p做哈希处理:开发者平时调试可以看指针的值是否相同,判断两个日志是不是指向同一个对象,但看不到真实的物理或虚拟地址。这个设计是刻意的,不是bug。
而no_hash_pointers就是那个“安全开关”:默认值是0,表示%p走哈希路径;把它设成1,就关闭指针哈希,%p会把真实指针直接打出来。看清楚,它影响的对象是普通的%p,不是所有指针格式。这一点很多人会混淆,下一节我展开讲。
2. 拆开内核的打印链路:%p的哈希到底发生在哪一步
2.1 从printk到lib/vsprintf.c的pointer()函数
Linux内核的格式化输出最终会汇聚到lib/vsprintf.c。printk 本质上也是先做格式化,再送到日志缓冲区。格式化字符串里遇到%p时,由pointer()函数统一处理。
在较新的内核里,pointer()对普通%p的处理逻辑大致是这样:
static char *ptr_to_id(char *buf, char *end, const void *ptr, struct printf_spec spec) { unsigned long hashval; if (no_hash_pointers) return pointer_string(buf, end, ptr, spec); // 直接显示真实指针 hashval = hash_long((unsigned long)ptr ^ siphash_key, 32); ... }概念上可以理解为:如果no_hash_pointers为真,直接返回真实指针的十六进制;如果为假,则对原始指针做一次带随机种子的哈希,得到一个“看起来像地址但实际上不是地址”的值。
这里的随机种子非常关键。内核在启动早期会生成一把随机密钥(实际实现中会涉及一个随机数种子),然后用它参与%p的哈希计算。因为种子每次启动都不同,所以同一个指针在不同启动中打印出来的哈希值也不同。你可以把这个过程类比成“给每个人发一个随机工号”:工号唯一,和本人一一对应,但光看工号你猜不出这个人的真实身份证号。
2.2 同一指针为什么在同一轮启动里哈希值相同
一个常见的疑问是:如果哈希用的是随机种子,那同一个指针在不同时刻打印,哈希值应该也变来变去才对,但实际观察到的结果是同一轮启动内一直都是同一个值。这是怎么做到的?
因为内核对指针哈希结果做了缓存。也就是说,第一次遇到某个指针时,它计算出一个哈希ID,之后再把指针和这个ID的对应关系记录下来。下一次再打印同一个指针时,直接查缓存返回上次的ID,不再重复计算。这样设计是有意的:开发者需要通过日志判断“这两个日志里出现的指针是不是同一个对象”,如果每次打印都得到不同值,那日志就完全没法对照了。
这个设计也有个副作用:如果你发现同一个指针在两段日志里哈希值不一样,说明至少有一个指针不是同一个对象,或者指向的内存已经被释放重用。这本身也能当排障线索用。
2.3 %p、%px、%pK:别把这三个“指针打印”混为一谈
很多人一听到 no_hash_pointers 就以为它能控制所有指针输出,这是最常见的误解。实际上内核格式串里的指针修饰符有好几类,行为差异很大。下面这张表是我在实际调试时经常拿来对照的:
| 格式符 | 默认行为 | no_hash_pointers=1 时 | 典型使用场景 |
|---|---|---|---|
%p | 输出哈希值,不是真实地址 | 输出真实地址 | 常规日志,安全默认 |
%px | 输出真实指针地址 | 仍输出真实地址,不受影响 | 必须打印真实地址的调试场景,慎用 |
%pK | 受kptr_restrict控制,权限不足时输出0或null | 仍受kptr_restrict控制 | 内核地址敏感信息,面向特权用户 |
重点提醒一下:%px和no_hash_pointers没有直接关系。即使no_hash_pointers=0,你用%px照样能打出真实指针。内核文档一直在强调%px要慎用,原因就在这里——它默认就是“裸奔”的。
%pK则走的是另一条权限控制链路,由kernel.kptr_restrict决定谁能看到真实指针,和no_hash_pointers是两套独立机制。后面第4节我会专门讲这两个参数组合时容易踩的坑。
3. 动手验证:打开 no_hash_pointers,做个最小复现
3.1 设置参数:启动参数、sysctl、sysfs三种姿势
no_hash_pointers既可以作为内核启动参数传入,也可以在系统运行期间通过 sysctl 动态修改。三种方式对应不同场景。
第一种,临时修改,适合正在排障、马上想验证的情况:
sysctl -w kernel.no_hash_pointers=1第二种,直接写/proc/sys/kernel/no_hash_pointers,效果和上面等价:
echo 1 > /proc/sys/kernel/no_hash_pointers第三种,持久化修改,把配置写进/etc/sysctl.conf或/etc/sysctl.d/下的某个文件里:
kernel.no_hash_pointers = 1如果你想在系统启动非常早的阶段就关闭哈希,比如想观察早期启动日志里的指针,sysctl是来不及的,这时候应该在内核引导参数里加一个no_hash_pointers。部分引导程序配置起来略有差异,但核心就是在内核命令行追加一个单词。
验证当前值很简单:
cat /proc/sys/kernel/no_hash_pointers输出0表示默认的哈希行为,输出1表示已经关闭哈希。修改后对已存在的日志没有影响,只对后续新产生的打印生效,这个别忽略。
3.2 写一个最小模块来观察变化
光看文档不如实际跑一次。我每次给别人演示这个参数时,都会用一个最小内核模块,代码只有几行。这里给一个可以照着敲的版本:
#include <linux/init.h> #include <linux/module.h> #include <linux/kernel.h> static int demo_var; static int __init demo_init(void) { pr_info("demo_var hashed = %p\n", &demo_var); pr_info("demo_var raw = %px\n", &demo_var); return 0; } static void __exit demo_exit(void) { } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE("GPL");编译加载步骤不复杂,前提是系统里有内核头文件和编译工具链。
make -C /lib/modules/$(uname -r)/build M=$PWD modules insmod demo.ko dmesg | tail -n 3加载前先看当前的no_hash_pointers。如果保持默认0,会看到类似这样的输出:
demo_var hashed = 000000006f07d3b8 demo_var raw = ffffffff9a1b2c3d然后执行:
sysctl -w kernel.no_hash_pointers=1 rmmod demo insmod demo.ko dmesg | tail -n 3这时的输出里,%p和%px显示的就是同一个值:
demo_var hashed = ffffffff9a1b2c3d demo_var raw = ffffffff9a1b2c3d看到两个值一致,说明参数确实生效了,而且影响的是printk后续所有普通%p打印。
这里有个操作细节:模块加载需要root权限;dmesg也可能被kernel.dmesg_restrict限制,普通用户看不到内核日志。如果dmesg没有任何输出,先检查这两个前提。
3.3 验证完了,怎么恢复现场
测试完一定要记得把参数恢复成默认值,否则可能给后续工作埋雷:
sysctl -w kernel.no_hash_pointers=0同时把测试模块卸载掉:
rmmod demo如果你在/etc/sysctl.conf或/etc/sysctl.d/里写了持久化配置,也要一并清掉,不然重启之后又会自动开启。我见过有人把测试参数留在sysctl配置里,结果第二天生产服务器重启后所有日志都在打真实内核地址,安全告警直接拉满。
另外一个容易忽略的细节:在容器里执行echo 1 > /proc/sys/kernel/no_hash_pointers经常会报只读文件系统错误。这是因为容器的/proc/sys默认是只读挂载的,这个参数实际上控制的是宿主机内核。要改就得在宿主机上操作,而且宿主机的安全策略还得允许修改。
4. 开了 no_hash_pointers 之后,你付出的安全代价可能比想象的大
4.1 KASLR与防绕过:真实地址泄露意味着什么
把no_hash_pointers打开,最直接的后果就是所有普通%p输出都会暴露真实的内核指针。从安全角度讲,这等于主动削弱了KASLR的效果。
攻击者拿到一个真实内核地址之后,可以结合已知的符号偏移,反推出内核镜像的加载基址。有了基址,很多漏洞利用技术就变得简单很多,比如定位特定的函数地址、寻找可用的gadget、绕过某些只依赖于地址随机化的缓解措施。
更要命的是,日志是会流动的。dmesg的内容会被系统日志服务收集,可能进入日志平台、运维系统、告警系统,甚至会通过工单系统转发给不同角色的人。日志一旦离开了本机,你根本无法控制它最终落到哪里。一次简单的调试操作,可能在几天后变成一条数据泄露记录。
所以生产环境我强烈不建议开no_hash_pointers。调试用的独立测试机可以开,但也要限时、限范围,用完之后立刻关。
4.2 与 kptr_restrict 组合时容易产生的误区
很多人以为把kernel.kptr_restrict设成2就万无一失,可以同时管住%pK和相关地址输出。但kptr_restrict管的是%pK,no_hash_pointers管的是普通%p,这是两条平行的路径。
这四行组合在实战里很关键:
| no_hash_pointers | kptr_restrict | 实际效果 |
|---|---|---|
| 0 | 0 | %p哈希,%pK任何进程可见,风险较高 |
| 0 | 1 | %p哈希,%pK仅特权进程可见,生产推荐 |
| 1 | 0 | %p和%pK都可能暴露真实地址,最不推荐 |
| 1 | 1 | %p暴露真实地址,%pK受权限限制,仍有明显风险 |
看到问题了吗?哪怕你把kptr_restrict设成1,只要no_hash_pointers开着,普通%p一样会把真实地址打出去。很多日志里用的恰恰是%p,不是%pK。所以不要以为开了一个安全参数就万事大吉,两个参数要一起检查。
4.3 我在模拟项目X里的一次教训
我在一个模拟项目X的测试环境里踩过一次实实在在的坑。当时为了定位一个驱动内存泄漏,我在测试机上开了no_hash_pointers,然后连续跑了两天压力测试。问题倒是定位到了,但当天下午安全扫描就报了告警,原因是这台测试机的核心日志被同步到了统一的日志平台,日志里出现了大量真实内核地址。
那次之后我定了一个规矩:凡是需要开no_hash_pointers的调试任务,必须先在命令里同时写好“关闭命令”,并且设置一个明确的关闭时间点。调试一结束,第一个动作就是关掉参数,再压缩日志、脱敏导出。宁可在调试时多费一点事,也别把地址泄露的风险留在不必要的地方。
5. 不开这个参数,我还能怎么拿真实地址
5.1 /proc/kallsyms:root下最方便的真实符号地址来源
如果你只是想确认某个函数或全局变量的真实地址,大可不必全局打开no_hash_pointers。/proc/kallsyms在满足权限条件时,直接提供内核符号的真实运行地址。
举个例子:
cat /proc/kallsyms | grep " demo_var"只要当前账号有足够权限,而且kptr_restrict允许,输出里就是真实地址。这种做法的好处是,它不影响dmesg日志内容,不会把大量真实指针刷到系统日志里,风险面小得多。
/proc/kallsyms拿到的地址是启动后经过KASLR重定位的实际地址。配合%px单独打印某个关键变量,就能在不全局开启no_hash_pointers的前提下完成指针对照。这也是我现在处理这类需求时的首选方案。
5.2 crash离线分析vmcore,比在活机器上开洞更安全
遇到内核panic或者需要深入分析内存布局的场景,我更喜欢用crash工具离线分析vmcore,而不是在活着的机器上开no_hash_pointers。
基本流程是准备好内核符号文件vmlinux和崩溃转储vmcore,然后进入crash shell:
crash> bt crash> struct my_struct ffffffff9a1b2c3d crash> rd ffffffff9a1b2c3d这种方式的优势很明显:不需要在运行中的系统上打开任何危险参数,分析的是死机时留下的内存快照,结果更加准确。唯一的要求是平时做好内核符号文件的归档,不然等出了问题再找对应的vmlinux就来不及了。
5.3 真需要printk输出真实地址时的安全做法
有些场景确实需要在代码里直接打印真实地址,比如调试驱动与硬件交互时的DMA地址、验证伙伴系统的页结构关系。这种情况下我的习惯是:用#ifdef DEBUG或单独的调试宏包裹对应的%px打印,确保正常编译的内核镜像里不会默认输出真实指针。
如果是在已有模块里临时加打印,我一般会打一句pr_debug,然后动态开启某个调试开关。这样即使日志进了集中平台,默认状态下也不会出现敏感地址。
说到底,no_hash_pointers是内核给调试者留的一扇后门,它确实方便,但使用成本很高。我个人在实际操作中的顺序是:先试/proc/kallsyms,再试crash离线分析,最后才考虑临时开no_hash_pointers。每次开之前我都会在终端里记一下两件事:当前时间,以及关闭它的命令。这个习惯虽然笨,但帮我避开过不止一次安全问题。