问题场景介绍
最近同事需要在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分两步:
- 把我们修改的接口做成DLL
- 将这个DLL注入目标程序
API HOOK是一门古老的技术,有很多大神写过许多方便调用的工具库,工具库可以帮我们实现了修改内存数据,跳转到我们指定的接口,这样我们不用再重复造轮子,这里我们选择了这位大神写的工具库(程序破解之 API HOOK技术),我们只要填入地址,实现自己的接口就可以了。
为什么选择这个库,因为他实现了对已知函数地址的HOOK,而大部分库为了进行攻击或者破解程序,只实现了对系统DLL API的劫持,这种实现方式为什么不能满足我们的要求呢?因为目标程序对标准C库的调用是静态链接的。那么我们是如何判断目标程序的C库是静态链接的?可以用我们刚才的测试程序,我们用VS2008编译时选择不同的链接方式,用IDA反编看一下有没有printf的汇编代码实现就知道了,这里不再赘述。
现在开始HOOK的工作,我们用上面的test_console1.exe来作为目标程序,看看假设我们没有test_console1.exe的源码,我们怎么搞定他:
接下来,我们会反复提到工具IDA Pro,这是一个强大的反汇编工具。因为我们这个目标程序比较简单,所以只用IDA对目标程序做静态分析就可以了,不需要进行更深入的逆向分析,如果需要了解更多逆向分析的资料,可以查看这篇文章逆向分析技巧。
- 确定目标程序使用的输出是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
- 找出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
- 查找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
- 现在已经找到了必要的信息,现在可以开始编写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;
-
将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
可以看到,完全达到我们期望的目标。