美文网首页
通过管道调用命令行程序输出延时问题分析解决(Windows)

通过管道调用命令行程序输出延时问题分析解决(Windows)

作者: craoy | 来源:发表于2018-07-14 01:18 被阅读114次

问题场景介绍

最近同事需要在Python下使用Subprocess.Popen调用外部命令行程序(假设称为external.exe),external.exe需要执行几十秒,执行过程中会输出很多信息。

我们期望在popen调用时可以及时获取这些信息进行处理,但是实际测试时发现,在命令行下直接执行external.exe会立即显示程序输出信息,而在popen时却要等到程序执行快结束了才能获取到这些信息。

为什么popen调用外部命令行程序,程序输出会产生延迟

我们知道命令行程序打印输出是通过printf(宽字符是wprintf),最终会写到标准输出stdout,那么Popen执行与命令行下直接执行有什么区别呢?

  • popen:程序输出内容到stdout --> popen pipe
  • 命令行:程序输出内容到stdout --> cmd.exe终端

stdout作为输出流,是有缓冲区的,那么会不会是stdout在pipe跟终端模式下的缓冲策略不同呢,我们来看下stdout在linux下(找不到windows的资料)缓冲策略是:

  • stdout(TTY) : 行缓冲
  • stdout(not a TTY): 全缓冲

正如你所看到的,stdout缓冲的策略有些特别:取决于流是不是一个交互设备(TTY)。理由在于,如果stdout是一个终端的话,用户通常希望看着命令运行并等待结果输出,因此及时输出数据是必要的。另一方面,如果输出流不是终端,意味着输出可以后期输出,因此效率更加重要(满缓冲)。

虽然这是linux的策略,但是我们可以猜测,windows也很有可能采取了同样的策略,如果是这个问题,那么怎么办?
有两个解决方案:

  • 关闭缓冲:setbuf(stdout,NULL);,将缓冲策略修改为无缓冲
  • 立即输出缓冲:fflush(stdout);,在printf之后立即输出缓冲

瞎猜为虚,代码为实,我们写代码确认一下是不是这个问题:

命令行测试程序源码:


python Subprocess.Popen调用代码

# coding: utf-8
import subprocess
import sys
from datetime import datetime

if len(sys.argv) != 2:
    print('usage: python test_popen.py xxx.exe')
    exit(-1)

print('[%s]: start...' % datetime.now().strftime('%X'))

p = subprocess.Popen(
    sys.argv[1],
    stderr=subprocess.PIPE, stdout=subprocess.PIPE, shell=True)

while True:
    s = p.stdout.readline()
    if len(s) == 0:
        break
    print('[%s]: stdout: %s' % (datetime.now().strftime('%X'), s[:-1]))

执行结果:


问题到了这里应该就很简单了,联系外部程序的提供方改一下代码就行了,可是他们说找不到源码了,卧槽,感觉心里一万头草泥马在奔腾。可是问题还得解决,现在怎么办?

没有源码,如何解决

现在想让程序在打印输出以后加上fflush,比如把原来的程序逻辑 xxx_code -> printf 修改为 xxx_code -> my_printf(printf + fflush),那么怎么把这个修改注入目标程序呢?

我们可能需要点黑货了,有门技术叫做API HOOK也叫API劫持,主要原理是在程序运行中动态修改目标函数地址的内存数据,使用jmp语句跳转到你的函数地址,执行完后再恢复内存数据。

API HOOK 有很多方式,我们选择最常用的DLL注入的方式。DLL注入的HOOK分两步:

  1. 把我们修改的接口做成DLL
  2. 将这个DLL注入目标程序

API HOOK是一门古老的技术,有很多大神写过许多方便调用的工具库,工具库可以帮我们实现了修改内存数据,跳转到我们指定的接口,这样我们不用再重复造轮子,这里我们选择了这位大神写的工具库(程序破解之 API HOOK技术),我们只要填入地址,实现自己的接口就可以了。

为什么选择这个库,因为他实现了对已知函数地址的HOOK,而大部分库为了进行攻击或者破解程序,只实现了对系统DLL API的劫持,这种实现方式为什么不能满足我们的要求呢?因为目标程序对标准C库的调用是静态链接的。那么我们是如何判断目标程序的C库是静态链接的?可以用我们刚才的测试程序,我们用VS2008编译时选择不同的链接方式,用IDA反编看一下有没有printf的汇编代码实现就知道了,这里不再赘述。

现在开始HOOK的工作,我们用上面的test_console1.exe来作为目标程序,看看假设我们没有test_console1.exe的源码,我们怎么搞定他:

接下来,我们会反复提到工具IDA Pro,这是一个强大的反汇编工具。因为我们这个目标程序比较简单,所以只用IDA对目标程序做静态分析就可以了,不需要进行更深入的逆向分析,如果需要了解更多逆向分析的资料,可以查看这篇文章逆向分析技巧

  1. 确定目标程序使用的输出是printf还是wprintf,如果都用到了,那么两个都要进行HOOK。
    通过IDA反编工具,我们可以先尝试查找一下目标程序可能的格式化打印语句,比如在test_console1.exe就能找到如下汇编,我们可以轻松判断出是printf
.text:00401079                 push    eax
.text:0040107A                 push    offset a02d02d02dD ; "[%02d:%02d:%02d]: %d\n"
.text:0040107F                 call    _printf
.text:00401084                 add     esp, 14h
  1. 找出printf、fflush、vprintf的函数地址,在IDA中双击上面的_printf会跳转到printf的函数地址,也可以在IDA左侧的函数列表直接搜索_printf(反编以后前面有个下划线),OK,记录下这个地址0x004010E0
.text:004010E0 ; int printf(const char *, ...)
.text:004010E0 _printf         proc near 

fflush地址:0x0040AA80

.text:0040AA80 ; int __cdecl fflush(FILE *)
.text:0040AA80 _fflush         proc near

vprintf地址:0x00404360

.text:00404360 ; int __cdecl vprintf(const char *, va_list)
.text:00404360 _vprintf        proc near
  1. 查找stdout的地址,为什么需要找这个呢?
    因为stdout其实是个指针,指向了一个iobuf的结构体,如果在我们的hook的DLL里直接引用,访问的是动态链接库的stdout,而不是程序已经静态链接进去的stdout,那么你的修改就无法生效,因为操作的对象错了。
    那么如何来查找这个地址呢?
    我们来看一下编译器的stdio.h的头文件,我是用VS2008编译(假设不知道目标程序用哪个版本编的,但是这块标准库代码区别不大,基本上下面的分析方法还是可以很容易分析出你想要的数据),到VS2008的安装目录下搜stdio.h,VS2008默认是C:\Program Files (x86)\Microsoft Visual Studio 9.0,有如下定义:
#define stdout (&__iob_func()[1])
_CRTIMP FILE * __cdecl __iob_func(void);
struct _iobuf {
        char *_ptr;
        int   _cnt;
        char *_base;
        int   _flag;
        int   _file;
        int   _charbuf;
        int   _bufsiz;
        char *_tmpfname;
        };
typedef struct _iobuf FILE;

原来是通过__iob_func获取一个_iobuf数组,stdout是数组的第二个元素,那么我们直接在IDA搜一下__iob_func,没搜到,可能IDA没反出这个函数名。
现在怎么办?想想有没有其他线索,printf接口里肯定会往stdout写入数据,而这里_iobuf这个结构体的大小是32个字节,那么应该在调用__iob_func返回有以后应该会个32个字节的偏移操作,我们来看一下printf的汇编是不是有相关代码,看看下面这段,20h = 0x20 = 32,所以sub_4017E0应该就是__iob_func了,记下__iob_func函数地址0x004017E0:

.text:004011EB                 call    sub_4017E0      ; Finally handler 0 for function 4010E0
.text:004011F0                 add     eax, 20h
.text:004011F3                 push    eax
.text:004011F4                 push    1
.text:004011F6                 call    __unlock_file2
.text:004011FB                 add     esp, 8
.text:004011FE                 retn
.text:004011FF ; ---------------------------------------------------------------------------
.text:004011FF
.text:004011FF loc_4011FF:                             ; CODE XREF: _printf:loc_4011E9�j
.text:004011FF                 mov     eax, [ebp+var_20]
.text:004011FF _printf         endp
  1. 现在已经找到了必要的信息,现在可以开始编写HOOK的DLL了,贴一下部分代码:

这里有个细节说明一下,我们在访问fflush/printf/__iob_func这些目标程序静态链接的函数时,去掉了0x0040000,又加上了一个g_base_addr,这是什么意思呢?

因为windows运行程序时,每个进程都有独立的地址空间,这个地址空间的起始地址我们称之为基地址。我们在反编译时多出来的0x00400000是IDA为反编的代码分配的基地址,而我们在程序运行时可以通过GetModuleHandle(NULL);获取当前程序的基地址。

static CAdHookApi     gHooks;
DWORD g_base_addr;

#define ADDR_FFLUSH         (0x0000AA80)
#define ADDR_IOB_FUNC       (0x000017E0)
#define ADDR_PRINTF         (0x000010E0)
#define ADDR_VPRINTF        (0x00004360)

typedef int (__cdecl *pfn_printf_t)(const char *fmt, ...);
typedef int (__cdecl *pfn_vprintf_t)(const char *fmt, va_list ap);
typedef _iobuf *(__cdecl *pfn__iob_func_t)(void);
typedef int (__cdecl *pfn_fflush_t)(FILE *);

void fflush_stdout(void){
    pfn__iob_func_t pfn__iob_func = (pfn__iob_func_t)(g_base_addr + ADDR_IOB_FUNC);
    pfn_fflush_t pfn_fflush = (pfn_fflush_t)(g_base_addr + ADDR_FFLUSH);

    pfn_fflush(&(pfn__iob_func()[1]));
}

int my_printf(const char *fmt, ...){
    CAdAutoHookApi autoHook(&gHooks, my_printf);

    pfn_printf_t pfn_printf = (pfn_printf_t)(g_base_addr + ADDR_PRINTF);
    pfn_vprintf_t pfn_vprintf = (pfn_vprintf_t)(g_base_addr + ADDR_VPRINTF);
    va_list ap;
    
    pfn_printf("hack ");
    va_start(ap, fmt);
    int ret = pfn_vprintf(fmt, ap);
    va_end(ap);
    fflush_stdout();

    return ret;
}

DllMain, DLL_PROCESS_ATTACH:

case DLL_PROCESS_ATTACH: {
        g_base_addr = (DWORD)GetModuleHandle(NULL);
        void *addr = (void *)(g_base_addr + ADDR_PRINTF);
        gHooks.Add(addr, my_printf, NULL, 0, 0);
        gHooks.BeginAll();
        logOutput("ApiDebugger Loaded.\r\n");
    }
    break;
  1. 将HOOK的DLL注入目标程序的exe中,CFF Explorer这个工具可以方便的实现这个功能,为了对比验证我们将目标程序另存为test_console1_hack.exe


将test_console1_hack.exe以及hook_test_console1.dll放在同一目录下,测试一下我们注入的效果:

python test_popen.py test_console1_hack.exe
[01:17:26]: start...
[01:17:28]: stdout: hack [01:17:28]: 4
[01:17:29]: stdout: hack [01:17:29]: 3
[01:17:30]: stdout: hack [01:17:30]: 2
[01:17:31]: stdout: hack [01:17:31]: 1
[01:17:32]: stdout: hack [01:17:32]: 0

可以看到,完全达到我们期望的目标。

相关文章

网友评论

      本文标题:通过管道调用命令行程序输出延时问题分析解决(Windows)

      本文链接:https://www.haomeiwen.com/subject/oxykpftx.html