《C++控制台程序模拟SQL注入与逆向分析》
同学们好,欢迎回来,我又来胡说八道了,练习讲课找工作。
之前我们用C++和IDA玩过函数重载、引用和指针的底层分析。今天我们把思路往前推一步——看看程序在真实世界中是怎么被攻击的。
我们今天的目标是:
用C++写一个带SQL注入漏洞的登录程序,看看攻击者是怎么绕过密码的;
用IDA打开这个程序,从汇编层面看看漏洞到底长什么样。
你会发现,无论代码写得多么高级,漏洞的本质,最终都会体现在底层的指令里。准备好了吗?我们开始。
第一部分:写一个有漏洞的登录程序
(打开Dev-C++,新建一个项目,边写边讲)
我们先写一个最简单的控制台登录程序。它的功能是——输入用户名和密码,查询数据库,如果匹配就登录成功。
但为了演示漏洞,我们先不用真实的数据库,而是用内存数据模拟查询逻辑。这样就不需要安装MySQL,省去环境配置的时间。
#include <iostream>
#include <string>
using namespace std;
// 模拟数据库中的用户数据
struct User {
string username;
string password;
};
// 模拟数据库表(只有一条测试数据)
User users[] = {
{"admin", "admin123"}
};
// 有漏洞的登录函数
bool loginLouDong(string username, string password) {
// 关键漏洞:直接拼接用户输入来构造SQL语句(这里是模拟查询条件)
string sql = "SELECT * FROM users WHERE username = '"
+ username + "' AND password = '" + password + "'";
cout << "[执行的SQL] " << sql << endl;
// 模拟查询:遍历内存数据
for (int i = 0; i < 1; i++) {
if (users[i].username == username && users[i].password == password) {
return true;
}
}
return false;
}
int main() {
string username, password;
cout << "========== 用户登录系统 ==========" << endl;
cout << "请输入用户名: ";
getline(cin, username);
cout << "请输入密码: ";
getline(cin, password);
cout << endl << "--- 使用漏洞版本登录 ---" << endl;
if (loginLouDong(username, password)) {
cout << "登录成功!欢迎," << username << endl;
} else {
cout << "用户名或密码错误" << endl;
}
return 0;
}
大家看,关键问题就在这里:我们直接把用户输入的username和password拼接到查询语句里了。
正常用户输入admin和admin123时,SQL语句是:
SELECT * FROM users WHERE username = 'admin' AND password = 'admin123'
这个没问题,能查到数据。但攻击者不会这么老实。
假设攻击者知道系统里有一个用户叫admin,他在密码框里输入这样一串东西:
' OR '1'='1
(输入并回车)
我们来分析一下刚才发生了什么。程序拼接出来的SQL语句变成了:
SELECT * FROM users WHERE username = 'admin' AND password = '' OR '1'='1'
关键点来了:'1'='1'永远为真。整个条件的逻辑变成了“用户名等于admin 且 密码等于空 或者 1=1”。因为1=1恒成立,整个条件恒为真,所以程序返回了成功。
这就是SQL注入——攻击者通过输入特殊字符,改变了程序的执行逻辑。
第二部分:修复漏洞——使用参数化查询
那怎么修复这个漏洞呢?核心原则是:永远不要把用户输入直接拼接到SQL语句里。
在C++里,如果我们使用真实的数据库(比如MySQL Connector/C++),可以使用预处理语句(Prepared Statement)。这里我们模拟一下它的思想:
// 安全的登录函数(使用预处理语句思想)
bool loginSafe(string username, string password) {
// 先用占位符(?)表示参数位置,不拼接用户输入
string sql_template = "SELECT * FROM users WHERE username = ? AND password = ?";
cout << "[SQL模板] " << sql_template << endl;
// 模拟预处理:将用户输入作为纯数据绑定到参数位置
cout << "[参数1(用户名)] " << username << endl;
cout << "[参数2(密码)] " << password << endl;
// 模拟查询:遍历内存数据(这里仍然使用等值比较)
for (int i = 0; i < 1; i++) {
if (users[i].username == username && users[i].password == password) {
return true;
}
}
return false;
}
注意这里的区别:SQL语句的骨架先用?占位,用户输入作为纯数据绑定进去。攻击者输入的' OR '1'='1在这个场景下,就是一个普通的字符串,不会被当作SQL代码执行。
我们在安全版本里输入同样的攻击字符串:
text
用户名: admin
密码: ' OR '1'='1
因为程序现在拿' OR '1'='1去和数据库里的admin123做等值比较,结果不匹配,所以登录失败。
第三部分:用IDA逆向分析——在底层看漏洞的本质
切换到IDA界面,好,现在关键来了。我们已经看到了漏洞在源代码层面是什么样的。但如果攻击者拿不到你的源代码呢? 他只有一个编译好的exe文件,还能不能找到这个漏洞?
答案是:能。 这就是逆向分析的力量。
我们把有漏洞的那个版本编译成exe,然后用IDA打开它。
先按Ctrl+F搜索main函数。
双击它,IDA帮我们定位到了使用这个字符串的地方。现在我们来看这段汇编代码:
; ============================================================
; 1. 准备参数(从右往左压栈)
; ============================================================
lea eax, [ebp+p_password] ; 获取临时密码对象的地址
mov [esp+4], eax ; 将密码地址作为第2个参数压栈
lea eax, [ebp+p_username] ; 获取临时用户名对象的地址
mov [esp], eax ; 将用户名地址作为第1个参数压栈
; ============================================================
; 2. 调用登录函数
; ============================================================
mov [ebp+fctx.call_site], 4 ; (异常处理相关,可忽略)
call __Z12loginLouDongSsSs ; 调用 loginLouDong(用户名, 密码)
; 返回值(bool)保存在 al 寄存器中
; ============================================================
; 3. 接收返回值
; ============================================================
mov byte ptr [ebp+lpuexcpt], al ; 将 al 中的返回值保存到内存变量 lpuexcpt
; 为什么要保存?因为后续析构函数会污染 eax
双击Z12loginLouDongSsSs这个函数进去
第1步:开头 + 用户名
lea eax, [ebp+__lhs] ; 准备一个临时字符串
mov edx, [ebp+p_username] ; 获取用户输入的用户名
mov [esp+8], edx ; 参数:用户名
mov dword ptr [esp+4], offset __lhs ; 参数:SQL开头部分
mov [esp], eax ; 返回值位置
call __ZStplIcSt11char_traitsIcESaIcEESbIT_T0_T1_EPKS3_RKS6_
; 拼接:SQL开头 + 用户名
这一步完成:"SELECT * FROM users WHERE username = '" + 用户名
第2步:追加中间部分
lea eax, [ebp+var_24] ; 新临时字符串
mov dword ptr [esp+8], offset __rhs ; 参数:"' AND password = '"
lea edx, [ebp+__lhs] ; 上一步的结果
mov [esp+4], edx
mov [esp], eax
call __ZStplIcSt11char_traitsIcESaIcEESbIT_T0_T1_ERKS6_PKS3_
; 拼接:(上一步结果) + "' AND password = '"
这一步完成:"SELECT ... '" + 用户名 + "' AND password = '"
第3步:追加密码
lea eax, [ebp+var_28] ; 新临时字符串
mov edx, [ebp+p_password] ; 获取用户输入的密码
mov [esp+8], edx
lea edx, [ebp+var_24] ; 上一步的结果
mov [esp+4], edx
mov [esp], eax
call __ZStplIcSt11char_traitsIcESaIcEESbIT_T0_T1_ERKS6_S8_
; 拼接:(上一步结果) + 密码
这一步完成:"SELECT ... '" + 用户名 + "' AND password = '" + 密码
第4步:收尾
lea eax, [ebp+sql] ; 最终的SQL字符串
mov dword ptr [esp+8], offset asc_48903A ; 参数:"'"
lea edx, [ebp+var_28] ; 上一步的结果
mov [esp+4], edx
mov [esp], eax
call __ZStplIcSt11char_traitsIcESaIcEESbIT_T0_T1_ERKS6_PKS3_
; 拼接:(上一步结果) + "'"
这一步完成:"SELECT ... '" + 用户名 + "' AND password = '" + 密码 + "'"
大家注意看,这里的逻辑是:先把用户输入的内容和SQL模板拼在一起,然后再执行。这就是漏洞的根源——输入内容和代码指令在同一个阶段被处理了。
(对比安全版本的汇编)
我们再看安全版本的汇编:
; 输出 "[SQL"
call __ZStls... ; cout << "[SQL"
; 输出 sql_template(带 ? 的模板)
call __ZStls... ; cout << sql_template
; 输出 "["
call __ZStls... ; cout << "["
; 输出 用户名(作为纯数据)
call __ZStls... ; cout << p_username
; 输出 "["
call __ZStls... ; cout << "["
; 输出 密码(作为纯数据)
call __ZStls... ; cout << p_password
同学们注意看,在安全版本的整个反汇编代码中,你找不到任何一个 __ZStpl(字符串拼接函数)的调用。 程序没有把用户输入和SQL模板‘粘’在一起,而是分别输出展示。
SQL 模板先被发送到数据库,并且预编译(解析、检查语法、生成执行计划)——但此时参数位置用 ? 占位,没有具体值。
SELECT * FROM users WHERE username = ? AND password = ?
然后,用户输入的数据作为纯参数,在预编译完成之后才发送给数据库:
参数1(用户名):admin
参数2(密码):' OR '1'='1
数据库此时只做一件事:把 ? 替换成对应的参数值,然后执行已经编译好的执行计划。 注意,这个替换是纯数据替换,' OR '1'='1 不会被当作 SQL 语句的一部分来解析,它就是一个普通的字符串。
这就是我们今天要传达的核心思想:在源代码层面,漏洞只是一行拼接代码;在汇编层面,漏洞表现为“指令和数据混在一起处理”。
第四部分:用MySQL数据库演示SQL注入
同学们,我们前面用C++模拟了内存里的“数据库”,但那个毕竟是假的。现在,我们直接操作一个真实的MySQL数据库,看看这条漏洞语句在数据库里到底是怎么被执行的。
-- 1. 创建一个测试数据库,如果存在就删除重建,确保环境干净
DROP DATABASE IF EXISTS sql_injection_demo;
CREATE DATABASE sql_injection_demo;
USE sql_injection_demo;
-- 2. 创建一个简单的用户表
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL,
password VARCHAR(50) NOT NULL
);
-- 3. 插入一条正常用户的测试数据
INSERT INTO users (username, password) VALUES ('admin', 'admin123');
-- 4. 确认数据已经插入成功
SELECT * FROM users;
如果是一个正常用户,输入了用户名admin和密码admin123,拼接后的语句是这样的:
-- 模拟C++漏洞版拼接后的SQL(正常情况)
SELECT * FROM users WHERE username = 'admin' AND password = 'admin123';
我们在密码框里输入' OR '1'='1。
-- 模拟C++漏洞版拼接后的SQL(攻击情况)
SELECT * FROM users WHERE username = 'admin' AND password = '' OR '1'='1';
大家看结果!即使我们输入了错误的密码,这条语句依然返回了admin的记录。
同学们,接下来我们模拟安全版代码——演示__ZStpl的替代方案
参数化查询的核心是“先发模板,后传数据”。第一步,我们把SQL语句的骨架发给MySQL,让它先进行预编译。
-- 1. 准备(PREPARE)语句模板:执行命令
PREPARE stmt FROM 'SELECT * FROM users WHERE username = ? AND password = ?';
-- 2. 将用户输入(包含注入代码)作为纯文本数据保存到变量中
SET @UserName = 'admin';
SET @password = ''' OR ''1''=''1'; -- 这就是攻击者的输入
-- 3. 执行预编译好的语句,把纯数据填入占位符
EXECUTE stmt USING @username, @password;
-- 4.关闭模板
DEALLOCATE PREPARE stmt;
结果是什么?Empty set,空结果!没有数据返回!
说明这条注入语句没有生效,因为' OR '1'='1在预编译执行时,被当成了password字段的一个值去进行比较,而不是作为SQL逻辑去执行。它不是一个改变了查询逻辑的指令,而只是一个没有匹配到任何人的密码字符串。
第五部分:总结——攻防一体的思维方式
好,我们来快速回顾一下今天的内容。
第一,我们看到了SQL注入的本质——程序把用户输入当代码执行了。根本原因是“拼接”,解决方案是“参数化查询”。
第二,我们用IDA看到了漏洞在底层的样子——漏洞版代码中连续调用ZStpl进行字符串拼接,而安全版没有ZStpl调用,SQL模板和参数是分开处理的。这让我们从汇编层面理解了漏洞的本质。
第三,我们在真实的MySQL数据库中验证了两种写法的区别——拼接版返回了数据(登录成功),参数化查询版返回空结果(登录失败)。
第四,需要特别强调的是:C++本身并不具备“防注入”能力——字符串就是字符串,它只管拼接和传递。真正保护我们的是MySQL自己的预处理(PREPARE)机制。编程语言通过MySQL的C API接口,把这个“先编译模板、后绑定参数”的能力调用过来而已。所以,是数据库的预处理机制在底层挡住了注入,不是编程语言。
今天的课就到这里。
附录:本课核心概念速查表
- SQL注入(SQL Injection)
| 项目 |
说明 |
| 定义 |
攻击者通过输入特殊字符,篡改SQL语句的原始逻辑,从而绕过验证或获取未授权数据。 |
| 根本原因 |
程序将用户输入直接拼接到SQL语句中,导致用户输入被当作代码执行。 |
| 经典攻击载荷 |
' OR '1'='1 —— 使WHERE条件恒为真,绕过密码验证。 |
| 核心本质 |
数据与指令混在一起。 |
- 参数化查询(Prepared Statement)
| 项目 |
说明 |
| 定义 |
先将SQL语句的“骨架”(带?占位符)发送给数据库进行预编译,再将用户输入作为纯数据绑定到参数位置。 |
| 为什么安全 |
数据库先固定了SQL语句的结构,用户输入只作为纯数据填入,不会被当作代码解析。 |
| 核心本质 |
数据与指令分离。 |
- C++底层函数名对照表
| 汇编中看到的符号 |
实际含义 |
对应C++代码 |
| __ZStpl |
std::operator+(字符串拼接) |
string sql = "SELECT " + username; |
| __ZStls |
std::operator<<(流输出) |
cout << "Hello"; |
| __ZStrs |
std::operator>>(流输入) |
cin >> username; |
| __ZNSsC1Ev |
std::string::string()(构造函数) |
string s; |
| __ZNSsD1Ev |
std::string::~string()(析构函数) |
对象生命周期结束时自动调用 |
| __ZNSs7compareERKSs |
std::string::compare()(字符串比较) |
if (s1 == s2) |
命名规则:__Z 是C++编译器的标准前缀;St 代表 std 命名空间;pl = plus(+),ls = left shift(<<),rs = right shift(>>)。
-
| IDA反汇编关键指令速查表 |
指令 |
含义 |
在课程中的作用 |
| lea eax, [ebp+var] |
取局部变量的地址 |
准备参数的地址 |
| mov [esp+4], eax |
将值存入栈中(压参数) |
函数调用前压参 |
| call __Z... |
调用函数 |
执行函数调用 |
| mov byte ptr [ebp+lpuexcpt], al |
保存返回值 |
从al寄存器取回返回值 |
| cmp byte ptr [ebp+lpuexcpt], 0 |
比较 |
判断登录是否成功 |
| jz loc_xxxxx |
条件跳转(如果相等则跳转) |
根据比较结果决定执行路径 |
通过网盘分享的文件:sql注入
链接: https://pan.baidu.com/s/1CRGkjlDjFOe8PzXis6XuRA?pwd=ht87 提取码: ht87