ARM 架构 PWN ret2libc初探
2025 领航杯 pwn 方向只有一个arm架构下的简单ret2libc(题目名为armrop)。这是我第一次打arm架构的pwn,正好看看arm pwn的打法。如果有amd pwn基础,上手其实很快。
本文环境配置和基础知识部分参考文章: 32位、64位下arm_pwn学习 (原文略有小错)
比赛提供的附件:
-
login_system:no canary,no pie,32位小端序
需要附件可以从本站如下路径下载:
-
/attachments/2025lhb/exp.py
:泄露 libc 代码 -
/attachments/2025lhb/exp2.py
:获取 shell 代码(至于为什么和获取libc的分开,后面会说) -
/attachments/2025lhb/libc-2.27.so
:比赛提供的 libc -
/attachments/2025lhb/login_system
:题目附件
环境配置
由于我的电脑是AMD64,不能直接运行ARM程序,所以需要额外配置一下
已经配置过的读者可以跳过这一部分
比赛现场装环境
1 | sudo apt-get update |
这样就可以本地运行了。这里我的文件名带有 _patch
是因为nop掉了一个定时
需要调试的话还需要继续配置:
1 | # 安装gdb调试工具 |
注意,在这种情况下,gdb调试命令与平常不同,next (n)
从 单步执行(不进入函数)
变成了类似平常gdb的 step (s)
单步执行(进入函数)
。至于其他不同,我还没研究过。针对这种情况,还不知道比较方便的解决办法。(虽然可以用 大量断点 + continue ©,但是太麻烦了)
ARM 寄存器和汇编基础
基础汇编
寄存器
lr、sp、pc三大寄存器
r14(LR) : 连接寄存器。每种模式下r14都有自身版组,它有两个特殊功能。
-
(1)保存子程序返回地址。使用BL或BLX时,跳转指令自动把返回地址放入r14中;子程序通过把r14复制到PC来实现返回(1)保存子程序返回地址。使用BL或BLX时,跳转指令自动把返回地址放入r14中;子程序通过把r14复制到PC来实现返回
-
当异常发生时,异常模式的r14用来保存异常返回地址,将r14如栈可以处理嵌套中断。
r13(SP) :堆栈指针。每一种异常模式都有其自己独立的r13,它通常指向异常模式所专用的堆栈,也就是说五种异常模式、非异常模式(用户模式和系统模式),都有各自独立的堆栈,用不同的堆栈指针来索引。这样当ARM进入异常模式的时候,程序就可以把一般通用寄存器压入堆栈,返回时再出栈,保证了各种模式下程序的状态的完整性。
r15(PC) :程序计数器。PC是有读写限制的。当没有超过读取限制的时候,读取的值是指令的地址加上8个字节,由于ARM指令总是以字对齐的,故bit[1:0]总是00。当用str或stm存储PC的时候,偏移量有可能是8或12等其它值。在V3及以下版本中,写入bit[1:0]的值将被忽略,而在V4及以上版本写入r15的bit[1:0]必须为00,否则后果不可预测。
32位寄存器
arm32只有16个32bit的通用寄存器,r0到r12,lr,pc,sp,函数调用时,前4个参数是压入寄存器的(r0、r1、r2、r3),后面的参数是压入栈中的
R0-R12:可在常规操作期间用于存储临时值,指针(到存储器的位置)等
R0在算术操作期间可称为累加器,或用于存储先前调用的函数返回结果
R7在处理系统调用时非常有用,因为它存储系统调用号
R11帮助我们跟踪用作帧指针的堆栈的边界
ARM上的函数调用约定指定函数的前四个参数存储在寄存器r0-r3中,剩下的再存入栈中
R13:SP(堆栈指针)。堆栈指针指向堆栈的顶部。堆栈是用于函数特定存储的内存区域,函数返回时将对其进行回收。因此,通过从堆栈指针中减去我们要分配的值(以字节为单位),堆栈指针可用于在堆栈上分配空间。换句话说,如果我们要分配一个32位值,则从堆栈指针中减去4
R14:LR(链接寄存器)。进行功能调用时,链接寄存器将使用一个内存地址进行更新,该内存地址引用了从其开始该功能的下一条指令。这样做可以使程序返回到“父”函数,该子函数在“子”函数完成后启动“父”函数调用
R15:PC(程序计数器)。程序计数器自动增加执行指令的大小。在ARM状态下,此大小始终为4个字节,在THUMB模式下,此大小始终为2个字节。当执行转移指令时,PC保留目标地址。在执行期间,PC在ARM状态下存储当前指令的地址加8(两个ARM指令),在Thumb(v1)状态下存储当前指令的地址加4(两个Thumb指令)。这与x86不同,x86中PC始终指向要执行的下一条指令
程序分析与攻击
第一次做arm题,没想到我的ida没法对arm反汇编,硬是盯着汇编做题。在写这篇文章时候才想起来有在线反编译工具(比如https://dogbolt.org/)。不过好在程序很简单,汇编并不复杂。如果你和我一样ida无法反编译,可以选择在线工具和ida对照着看。
首先main函数只调用两个函数:sub_10564
sub_105D0
sub_10564
是一个初始化函数,可以不管。其中alarm会限制程序时间为1分钟,本地调试可以nop掉(这就是为什么我之前的文件名是login_system_patch):
sub_105D0
是程序主要逻辑,如下:
很明显,输入Username部分可以输入512字节,而数组只有256字节,存在溢出,而且strncmp只比较前五个字符。只需要输入b"admin" + b"a"*(256-5) + p32(0xdeadbeef) + ...(rop链)
即可。
至于输入密码部分,和输入Username部分有相同的溢出,用同一个payload就可以。
那么泄露libc部分payload构造如下:
1 | puts_plt=elf.plt['puts'] |
接下来就是找控制r0的gadgets。
可以用Ropgadgets来找,但是我的Ropgadgets无法正确处理arm架构,只能硬看了。
这里可用的只找到 MOV R0, R3
(很多,这里选用了0x106c0
),没有直接的POP R0
。那么可以控制 R3 来间接控制 R0。在0x10730
,可以找到 POP {R3,PC}
那么方式就很明显,通过 POP {R3,PC}
将puts_got放入R3,然后MOV R0, R3
,就可以将puts_got放入R0。
1 | puts_plt=elf.plt['puts'] |
这样便泄露了puts的地址。
完善一下计算libc部分:
1 | from pwn import * |
可见libc已经成功泄露了
接下来比较投机取巧。按照常理,应该在payload后面添加返回地址进行二次读,但是尝试多次后,发现无法跳转,但是尝试过程发现libc基地址是不变的(比赛过程远程环境也没有变)。利用这一特性,直接在新的脚本(也可以在下面直接接着写),按照泄露libc的方式,填入/bin/sh和system地址
本地测试时,地址如下:
1 | [+] system 地址 0x408823d4 |
1 | from pwn import * |
利用成功: