第一部分:编写一个“密码和提示语都是拼出来”的程序
同学们好,欢迎回来,我是小菜鸟,继续练习讲课找工作,有岗位的坛友帮忙内推一下吧,谢谢。
今天主要是演示逆向工程中一个核心的对抗手段:隐藏关键信息,另外,用c++模拟演示web中的命令注入漏洞。
我们将编写一个程序,它的登录密码和所有提示语,都不是以明文形式存在的——它们被拆成了一个个字节,只有在运行时才会被拼回原样。而我们的任务,就是用IDA把这个“拼图”过程还原出来。
你会发现:在逆向工程面前,没有绝对的“隐藏”,只有“藏得深不深”。
#include <iostream>
#include <string>
#include <cstdlib>
using namespace std;
// ============================================================
// 1. 提示语:全部转为GBK十六进制码(Windows 7默认编码)
// ============================================================
// "请输入管理员密码" 的GBK编码:
// 请=C7 EB, 输=CA E4, 入=C8 EB, 管=B9 DC, 理=C0 ED, 员=D4 B1, 密=C3 DC, 码=C2 EB
const unsigned char msg_prompt[] = {0xC7, 0xEB, 0xCA, 0xE4, 0xC8, 0xEB, 0xB9, 0xDC, 0xC0, 0xED, 0xD4, 0xB1, 0xC3, 0xDC, 0xC2, 0xEB, 0x00};
// "登录成功" 的GBK编码:
// 登=B5 C7, 录=C2 BC, 成=B3 C9, 功=B9 A6
const unsigned char msg_success[] = {0xB5, 0xC7, 0xC2, 0xBC, 0xB3, 0xC9, 0xB9, 0xA6, 0x00};
// "密码错误" 的GBK编码:
// 密=C3 DC, 码=C2 EB, 错=B4 ED, 误=CE F3
const unsigned char msg_error[] = {0xC3, 0xDC, 0xC2, 0xEB, 0xB4, 0xED, 0xCE, 0xF3, 0x00};
// ============================================================
// 2. 密码:每个字符的ASCII码分散存放
// ============================================================
const char p1 = 0x32; // '2'
const char p2 = 0x30; // '0'
const char p3 = 0x32; // '2'
const char p4 = 0x34; // '4'
// ============================================================
// 3. 解码函数:把GBK字节数组拼回汉字字符串
// ============================================================
string decodeGBK(const unsigned char* bytes) {
string result;
for (int i = 0; bytes[i] != 0; i++) {
result += bytes[i];
}
return result;
}
string getSecretPassword() {
string pwd;
pwd += p1;
pwd += p2;
pwd += p3;
pwd += p4;
return pwd;
}
// ============================================================
// 4. 登录函数
// ============================================================
bool login() {
string input_pwd;
cout << decodeGBK(msg_prompt) << ": "; // 运行时才拼出 "请输入管理员密码"
cin >> input_pwd;
if (input_pwd == getSecretPassword()) {
cout << decodeGBK(msg_success) << endl; // 运行时拼出 "登录成功"
return true;
} else {
cout << decodeGBK(msg_error) << endl; // 运行时拼出 "密码错误"
return false;
}
}
// ============================================================
// 5. 命令注入漏洞函数
// ============================================================
void pingHost() {
string ip;
cout << "请输入要ping的IP地址: ";
cin >> ip;
string command = "ping -n 4 " + ip; // Windows 7 环境
cout << "[执行命令] " << command << endl;
system(command.c_str());
}
int main() {
cout << "========== 网络管理工具 ==========" << endl;
if (!login()) {
cout << "登录失败,程序退出。" << endl;
return -1;
}
while (true) {
cout << "\n请选择功能: " << endl;
cout << "1. Ping IP地址" << endl;
cout << "2. 退出" << endl;
cout << "请输入选项: ";
int choice;
cin >> choice;
if (choice == 1) {
pingHost();
} else if (choice == 2) {
break;
} else {
cout << "无效选项,请重新输入。" << endl;
}
}
return 0;
}
同学们,我们平时在屏幕上看到的汉字,在计算机内部其实都是一串数字。Windows 7 用的是 GBK 编码,每个汉字占 2 个字节。我们把这串十六进制码存到程序里,程序运行的时候再用 decodeGBK 函数把它拼回去。
为什么要这样做呢?因为如果直接把汉字写进程序里,在 IDA 的字符串窗口中就一目了然,攻击者很容易定位到关键逻辑。我们把这个编码拆散存放,就是给逆向分析增加一点干扰——虽然不能完全阻止,但能让分析的过程变得更曲折一些。”
同学们,如果这个程序是你写的,并且你把它发布了出去,你能保证别人找不到密码吗?答案是:不能。因为逆向工程可以绕过一切“表面”的隐藏。
第二部分:攻击手法一:IDA逆向分析,从内存中找线索
同学们,我们还是使用IDA工具加载exe程序,然后找到main函数,看到关键函数login:
call ___main
mov dword ptr [esp+4], offset asc_489034 ; "========== "
mov dword ptr [esp], offset __ZSt4cout ; std::ostream::sentry *
call __ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc ; std::operator<<<std::char_traits<char>>(std::ostream &,char const*)
mov dword ptr [esp], offset __ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_ ; std::endl<char,std::char_traits<char>>(std::ostream &)
mov ecx, eax
call __ZNSolsEPFRSoS_E ; std::ostream::operator<<(std::ostream & (*)(std::ostream &))
sub esp, 4
call __Z5loginv ; login(void)
xor eax, 1
test al, al
jz short loc_401B19
同学们,看这里。call login 执行完后,al 里存的是登录结果。但这里不是直接test al, al:它先用 xor eax, 1,把登录结果取反了,相当于 if (!login())。
接下来可以看到他有两个分支,如果看函数地址和十六进制码就按一下空格键(我一般用来看函数地址用),如果要看这种带分支的代码,再按一下空格键,这里可以看到三种颜色箭头。
绿色箭头:条件跳转(成功/成立时跳转)
含义:表示条件成立时(例如 jz、je、jnz、jg 等指令),程序会沿着这条线执行。
示例:如果比较结果相等,jz 跳转,走绿色线;否则继续执行下一条指令。
红色箭头:条件跳转(失败/不成立时跳转)
含义:表示条件不成立时(即条件跳转指令的“失败”分支),程序会沿着这条线继续执行到下一条指令。
注意:这里的“红色”分支通常是条件不满足时继续执行的路径,而不是跳转目标。
蓝色箭头:无条件跳转
含义:表示一定会跳转(例如 jmp 指令),没有条件判断,执行到此处必定走这条线。
这些汇编指令不记得也没关系,遇到就去查,见多了就记得了,关于ida对自定义函数重命名的事,补充一下相关规则。
| 组成部分 |
实际内容 |
含义 |
| __Z |
固定前缀 |
C++ 编译器的标准名称修饰前缀(部分编译器使用 _Z) |
| 5 |
数字 5 |
表示接下来的函数名长度是 5 个字符 |
| login |
函数名 |
实际的函数名,正好 5 个字符(l-o-g-i-n) |
| v |
参数类型缩写 |
表示 void,即该函数不接受任何参数 |
因为这里核心是分析登录函数,要找出真实的密码,所以先进入login函数内部看看,调用decodeGBK函数,将返回的字符串“请输入管理员密码:”输出到控制台。
lea eax, [ebp+var_28] ; 接收返回值
mov dword ptr [esp+4], offset __ZL10msg_prompt ; 压入第二个参数
mov [esp], eax ; 压入第一个参数(返回值地址)
mov [ebp+fctx.call_site], 1 ;
call __Z9decodeGBKPKh ; 调用 decodeGBK
lea eax, [ebp+var_28] ; 取解码后的字符串地址
mov [esp+4], eax ; 作为第二个参数压栈
mov dword ptr [esp], offset __ZSt4cout ; 压入 cout 对象
call __ZStlsIcSt11char_traitsIcESaIcEERSt13basic_ostreamIT_T0_ES7_RKSbIS4_S5_T1_E ; 10. 输出字符串
同学们,这就是GBK编码还原中文字符串的过程,中文字符按照GBK编码,占2 个字节,把它拼回去,就能输出成汉字了。
隐藏字符串只是增加了分析者的工作量,但并不能阻止逆向工程。在逆向分析中,数据流追踪(从数据定义到函数调用)比直接搜索字符串更可靠。
接下来继续往下看,这里是因为程序是我们自己写的,所以是顺序看,常规方法是通过分支找关键跳-然后向上翻,找到关键函数,解密关键函数。
lea eax, [ebp+var_24] ; 取 var_24 的地址,存真实密码用
mov [esp], eax ; 作为第一个参数(返回值容器)压栈
call __Z17getSecretPasswordv ; 调用 getSecretPassword()
lea eax, [ebp+var_24] ; 取 var_24 的地址(真实密码)
mov [esp+4], eax ; 作为第二个参数压栈
lea eax, [ebp+var_2C] ; 取 var_2C 的地址(用户输入的密码)
mov [esp], eax ; 作为的第一个参数压栈
call __ZSteqIc... St11char_traitsIS3_ESaIS3_EESC_ ; 调用 std::operator==,比较两个字符串,这里操作符重载
mov byte ptr [ebp+lpuexcpt], al ; 将比较结果(true/false)保存到局部变量
cmp byte ptr [ebp+lpuexcpt], 0 ; 比较结果是否为 false
jz short loc_4017DB ; 如果为 false(密码不匹配),跳转到错误处理
到这一步了,同学们有什么想法?是不是鼠标放到getSecretPassword这个函数,进去看看。
mov dword ptr [esp], 32h ; '2'
mov ecx, [ebp+arg_0] ; 取 std::string 对象的地址(this 指针)
call __ZNSspLEc ; 调用 string::operator+=(char),追加字符 '2'
mov dword ptr [esp], 30h ; '0'
mov ecx, [ebp+arg_0]
call __ZNSspLEc ; 追加字符 '0'
mov dword ptr [esp], 32h ; '2'
mov ecx, [ebp+arg_0]
call __ZNSspLEc ; 追加字符 '2'
mov dword ptr [esp], 34h ; '4'
mov ecx, [ebp+arg_0]
call __ZNSspLEc ; 追加字符 '4'
jmp short loc_401684 ; 跳转到清理和返回代码
现在看到的就是 getSecretPassword() 函数的核心。它做的事情非常简单,就是把 '2'、'0'、'2'、'4' 这四个字符依次追加到一个空字符串的末尾。也就是说真正的密码就是2024。
同学们再思考一下:那如果是比较复杂的函数,我们看不懂里面的代码怎么办呢?有没有办法直接在函数调用后获取到真实的密码呢?
IDA除了静态分析还具有动态调试功能,在菜单栏选择window调试器,在汇编代码选择call Z17getSecretPasswordv这一行,F2打上断点,断点的意思是程序运行到这里就会停下来,可以让我们看到此刻运行中的程序内存情况。而我们指令和数据都在内存中,通过观察内存,就能拿到我们想要的数据,而不需要去阅读里面的实现代码。
运行之后,停在call这一行,再按一下F8单步步过,步过的意思就是不进入函数内部(如果想要进入函数内部看就是按F7单步步入),左上方是反汇编窗口同学们已经见过,右上方是寄存器窗口,左下方是数据窗口,右下方是堆栈窗口。
text:0040174E call __Z17getSecretPasswordv ; getSecretPassword(void)
text:00401753 lea eax, [ebp+var_24]
text:00401756 mov [esp+4], eax
此时ebp+var_24值是0028FE64,这怎么不是2024呢?因为lea是传地址,mov 带中括号是取地址里面的值,mov后面不带中括号才是传地址,那么我们在hex view窗口按g键输入0028FE64,看到8C 12 35 00,在 x86 电脑上,数据是以小端序存储的,所以这是0x35128c,这也是不是2024。
| 模式 |
规则 |
例子(存储 0x0035128C) |
| 大端序(网络字节序) |
高位字节在低地址(和书写顺序一样) |
00 35 12 8C(从左到右读) |
| 小端序(x86/x64 默认) |
低位字节在低地址(字节顺序反过来) |
8C 12 35 00(从右到左读) |
同学们,指针并不会只是一级指针,它可能是多级指针,怎么判断是数据还是地址呢?像这种无意义地址和堆栈地址接近的就可能是地址,数据一般都是ASCII码,在数据窗口能看到一串有意义的字符,实在判断不了,还是在数据窗口按g,输入0x35128c这个值,看看能不能跳转,再看数据窗口,出来了2024,从而找到了正确的密码。
00351280 04 00 00 00 04 00 00 00 00 00 00 00 32 30 32 34 ............2024
第三部分:攻击手法二:命令注入,获取系统权限
同学们,输入2024,登录成功后,程序允许我们执行 ping 命令。
输入 127.0.0.1 时,正常执行:
ping -n 4 127.0.0.1
现在输入 127.0.0.1 & whoami:
ping -n 4 127.0.0.1 & whoami
不好,老师翻车了,该死的程序为什么会无限循环,逻辑没问题啊,那么为什么正常输入ip程序可以正常执行,发动攻击后程序无限循环了?我们找到比较前的cin函数,F2下上断点。
lea eax, [ebp+var_C] ; 取 var_C 的地址(这就是 choice 变量的地址)
mov [esp], eax ; 将这个地址作为参数压栈
mov ecx, offset __ZSt3cin ; 将 cin 对象的地址存入 ecx(this 指针)
call __ZNSirsERi ; 调用 std::istream::operator>>(int &)
sub esp, 4 ; 恢复栈平衡
mov eax, [ebp+var_C] ; 读取用户输入的值到 eax,用于后面if比较
F8一直单步步过往前走,正常选择1,输入127.0.0.1 & whoami,继续F8走完一个循环,没什么问题,再来第二个循环,我们发现程序没有暂停接受我的输入,而是直接取了0,为什么?看看控制台。
请选择功能:
1. Ping IP地址
2. 退出
请输入选项: 1
请输入要ping的IP地址: 127.0.0.1 & whoami
[执行命令] ping -n 4 127.0.0.1
可以看到第一次攻击命令执行后 & whoami并没有执行,我们看代码:int choice(choice只能是数字),cin >> choice 有几种停止方式:正常读取完一个数字后遇到空格或换行就停;如果读到的不是数字,就会进入错误状态,之后所有的 cin >> 操作都会直接跳过,choice 变成0。
这里我们修改一下,让127.0.0.1 & whoami被完整读取,修改程序代码,使用geline函数保证读一整行,cin >> choice这后面加一行cin.ignore();丢掉一个字符也就是回车符,防止后续geline脏读:
getline(cin,ip); //读字符串时使用getline读一整行。
cin >> choice
cin.ignore(); // 丢弃那个 '\n'
编译运行后,重新进行127.0.0.1 & whoami攻击
请输入选项: 1
请输入要ping的IP地址: 127.0.0.1 & whoami
[执行命令] ping -n 4 127.0.0.1 & whoami
正在 Ping 127.0.0.1 具有 32 字节的数据:
来自 127.0.0.1 的回复: 字节=32 时间<1ms TTL=64
来自 127.0.0.1 的回复: 字节=32 时间<1ms TTL=64
来自 127.0.0.1 的回复: 字节=32 时间<1ms TTL=64
来自 127.0.0.1 的回复: 字节=32 时间<1ms TTL=64
127.0.0.1 的 Ping 统计信息:
数据包: 已发送 = 4,已接收 = 4,丢失 = 0 (0% 丢失),
往返行程的估计时间(以毫秒为单位):
最短 = 0ms,最长 = 0ms,平均 = 0ms
lenovo-pc\lenovo
程序不仅执行了ping,还执行了 whoami,显示了当前登录的用户名,这里同学们可以替换成其他的命令试试。
system() 会把整个字符串交给 cmd.exe 解析,&、|、&& 等符号都会被解释为命令连接符。这就是命令注入的根源。
第四部分:总结——逆向分析与漏洞利用的完整链条
同学们,今天我们演示了一条完整的攻击链,从分析汇编代码,找出真实密码,到实现命令注入,只用了三步:
| 步骤 |
操作 |
技术手段 |
关键突破点 |
| 第一步 |
找到隐藏的密码 |
IDA静态分析 + 数据流追踪 |
GBK编码被还原,密码被拼接 |
| 第二步 |
登录成功 |
动态调试 + 内存查看 |
多级指针追踪,抓取明文密码 |
| 第三步 |
获取系统权限 |
命令注入 |
system() + & 命令连接符 |
IDA中函数重命名(Name Mangling)解析,除了Z5loginv,还有Z9decodeGBKPKh = decodeGBK(const unsigned char*),相关释义如下:
| 符号模式 |
含义 |
示例 |
| __Z + 数字 + 函数名 + 参数缩写 |
C++ 函数名改编 |
__Z5loginv = login(void) |
| 数字 |
表示函数名长度 |
5 = 函数名有5个字符 |
| v |
void |
无参数 |
| Ss |
std::string |
字符串参数 |
| PKh |
const unsigned char* |
常量无符号字符指针 |
最后,再补充一下这节课相关的C++ 函数名速查和动态调试实战常用快捷键
| IDA中的符号 |
C++ 原始代码 |
含义 |
| __Z5loginv |
login(void) |
登录函数 |
| __Z9decodeGBKPKh |
decodeGBK(const unsigned char*) |
GBK解码函数 |
| __Z17getSecretPasswordv |
getSecretPassword(void) |
获取密码函数 |
| __ZSteqIc... |
std::operator== |
字符串比较操作符 |
| __ZStpl |
std::operator+ |
字符串拼接操作符 |
| __ZStls |
std::operator<< |
流输出操作符 |
| __ZNSspLEc |
std::string::operator+=(char) |
字符串追加字符 |
| __ZNKSs5c_strEv |
std::string::c_str(void) |
获取C风格字符串指针 |
| _system |
system() |
执行系统命令 |
| 快捷键 |
功能 |
| F2 |
设置/取消断点(IDA/OD通用) |
| F7 |
单步步入(进入call内部) |
| F8 |
单步步过(不进入call内部) |
| F9 |
继续运行程序 |
| G |
跳转到指定地址(IDA中) |
| 空格 |
切换图形/文本视图(IDA中) |
| Shift+F12 |
打开字符串窗口(IDA中) |
| Ctrl+F |
搜索当前窗口 |
最后给同学们留2个作业,编程方面就把GBK编码两个16进制互换位置,用的时候再换回来再解析,不然x32 dbg和OD工具有可能直接解码识别出来GBK编码的,逆向作业还是修改跳转和程序密码,自由发挥即可。
课程笔记和视频:c++控制台模拟web中的命令注入漏洞
链接: https://pan.baidu.com/s/1ZY_KAXmUSe6yUtb85ul7Lw?pwd=nv16 提取码: nv16