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

问题场景介绍

最近同事需要在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

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,123评论 6 490
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,031评论 2 384
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 156,723评论 0 345
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,357评论 1 283
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,412评论 5 384
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,760评论 1 289
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,904评论 3 405
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,672评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,118评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,456评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,599评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,264评论 4 328
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,857评论 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,731评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,956评论 1 264
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,286评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,465评论 2 348

推荐阅读更多精彩内容