Skip to content

Commit f04aeab

Browse files
committed
lcfs-writer-erofs: reject hardlinked whiteout inodes with EINVAL
A whiteout (chardev with rdev=0) represents the absence of a file, so it is semantically invalid for it to have nlink>1. This was found via fuzzing in composefs-rs, currently we get a heap use-after-free when parsing such files. Assisted-by: OpenCode (Claude Sonnet 4.6) Signed-off-by: Colin Walters <walters@verbum.org>
1 parent 25d0cd3 commit f04aeab

2 files changed

Lines changed: 119 additions & 1 deletion

File tree

libcomposefs/lcfs-writer-erofs.c

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1766,7 +1766,17 @@ static struct lcfs_node_s *lcfs_build_node_from_image(struct lcfs_image_data *da
17661766
}
17671767

17681768
if (type == S_IFCHR && node->inode.st_rdev == 0) {
1769-
errno = ENOTSUP; /* Use this to signal that we found a whiteout */
1769+
/* A whiteout (chardev rdev=0) with nlink>1 is semantically
1770+
* invalid: a whiteout represents the absence of a file, so it
1771+
* cannot be hard-linked. Reject the image explicitly rather
1772+
* than silently skipping what would be a dangling alias. */
1773+
if (node->inode.st_nlink > 1) {
1774+
free(hash_remove(data->node_hash, &ht_entry));
1775+
errno = EINVAL;
1776+
return NULL;
1777+
}
1778+
free(hash_remove(data->node_hash, &ht_entry));
1779+
errno = ENOTSUP; /* Signal to caller: skip this whiteout entry */
17701780
return NULL;
17711781
}
17721782

tests/test-lcfs.c

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@
33

44
#include "lcfs-writer.h"
55
#include "lcfs-mount.h"
6+
#include "lcfs-erofs.h"
7+
#include "erofs_fs_wrapper.h"
68
#include <string.h>
79
#include <assert.h>
810
#include <unistd.h>
911
#include <errno.h>
12+
#include <endian.h>
13+
#include <sys/stat.h>
1014

1115
static inline void lcfs_node_unrefp(struct lcfs_node_s **nodep)
1216
{
@@ -121,6 +125,106 @@ static void test_add_uninitialized_child(void)
121125
assert(errno == EINVAL);
122126
}
123127

128+
/* Regression test for heap-use-after-free when loading an EROFS image that
129+
* contains a hardlinked whiteout (chardev with rdev=0, nlink>1).
130+
*
131+
* A whiteout represents the absence of a file, so nlink>1 is semantically
132+
* invalid. The loader must reject such images with EINVAL rather than
133+
* silently processing them (which previously caused a use-after-free via a
134+
* stale node_hash entry when the alias dirent appeared before the canonical
135+
* one in the directory block).
136+
*
137+
* We construct a minimal EROFS image in memory rather than loading a binary
138+
* fixture, so the test is self-contained.
139+
*/
140+
static void test_hardlinked_whiteout_load(void)
141+
{
142+
/*
143+
* Image layout (2 blocks = 8192 bytes, all in block 0's metadata area):
144+
*
145+
* 0x000 lcfs_erofs_header_s (composefs header, 32 bytes)
146+
* 0x400 erofs_super_block (EROFS superblock, 128 bytes)
147+
* 0x480 erofs_inode_compact root dir, nid=36, 32 bytes
148+
* 0x4A0 inline dir data 3 dirents + names, 41 bytes
149+
* 0x4E0 erofs_inode_compact whiteout, nid=39, 32 bytes
150+
*/
151+
uint8_t image[2 * EROFS_BLKSIZ];
152+
memset(image, 0, sizeof(image));
153+
154+
/* Composefs header at offset 0 */
155+
struct lcfs_erofs_header_s *cfs = (struct lcfs_erofs_header_s *)image;
156+
cfs->magic = htole32(LCFS_EROFS_MAGIC);
157+
cfs->version = htole32(LCFS_EROFS_VERSION);
158+
159+
/* EROFS superblock at offset 1024 */
160+
struct erofs_super_block *sb =
161+
(struct erofs_super_block *)(image + EROFS_SUPER_OFFSET);
162+
sb->magic = htole32(EROFS_SUPER_MAGIC_V1);
163+
sb->blkszbits = EROFS_BLKSIZ_BITS;
164+
sb->root_nid = htole16(36); /* nid=36 → offset 36*32 = 0x480 */
165+
sb->inos = htole64(2);
166+
sb->blocks = htole32(2);
167+
sb->meta_blkaddr = htole32(0);
168+
sb->xattr_blkaddr = htole32(0);
169+
170+
/* Root directory inode (compact, 32 bytes) at offset 0x480, nid=36.
171+
* Data layout = FLAT_INLINE (tailpacked dir entries follow the inode). */
172+
const uint16_t root_nid = 36;
173+
const uint16_t wh_nid = 39; /* offset 39*32 = 0x4E0 */
174+
struct erofs_inode_compact *root_ino =
175+
(struct erofs_inode_compact *)(image + root_nid * EROFS_SLOTSIZE);
176+
root_ino->i_format = htole16((EROFS_INODE_FLAT_INLINE << EROFS_I_DATALAYOUT_BIT) |
177+
(EROFS_INODE_LAYOUT_COMPACT << EROFS_I_VERSION_BIT));
178+
root_ino->i_mode = htole16(S_IFDIR | 0755);
179+
root_ino->i_nlink = htole16(2);
180+
181+
/* Build inline directory data right after the root inode (offset 0x4A0).
182+
* 3 entries: "." (self), ".." (parent), "wh" (whiteout child).
183+
* Each dirent is 12 bytes; names start at offset 3*12 = 36. */
184+
uint8_t *dir = image + root_nid * EROFS_SLOTSIZE +
185+
sizeof(struct erofs_inode_compact);
186+
const uint16_t names_off = 3 * sizeof(struct erofs_dirent); /* 36 */
187+
/* "." at offset 36, ".." at 37, "wh" at 39 → total = 41 bytes */
188+
const uint32_t dir_size = names_off + 1 + 2 + 2; /* 41 */
189+
root_ino->i_size = htole32(dir_size);
190+
191+
struct erofs_dirent *de = (struct erofs_dirent *)dir;
192+
/* dirent[0]: "." */
193+
de[0].nid = htole64(root_nid);
194+
de[0].nameoff = htole16(names_off);
195+
de[0].file_type = EROFS_FT_DIR;
196+
/* dirent[1]: ".." */
197+
de[1].nid = htole64(root_nid);
198+
de[1].nameoff = htole16(names_off + 1);
199+
de[1].file_type = EROFS_FT_DIR;
200+
/* dirent[2]: "wh" */
201+
de[2].nid = htole64(wh_nid);
202+
de[2].nameoff = htole16(names_off + 3);
203+
de[2].file_type = EROFS_FT_CHRDEV;
204+
205+
memcpy(dir + names_off, ".", 1);
206+
memcpy(dir + names_off + 1, "..", 2);
207+
memcpy(dir + names_off + 3, "wh", 2);
208+
209+
/* Whiteout inode (compact, 32 bytes) at offset 0x4E0, nid=39.
210+
* chardev with rdev=0 and nlink=252 (>1 triggers EINVAL). */
211+
struct erofs_inode_compact *wh_ino =
212+
(struct erofs_inode_compact *)(image + wh_nid * EROFS_SLOTSIZE);
213+
wh_ino->i_format = htole16((EROFS_INODE_FLAT_PLAIN << EROFS_I_DATALAYOUT_BIT) |
214+
(EROFS_INODE_LAYOUT_COMPACT << EROFS_I_VERSION_BIT));
215+
wh_ino->i_mode = htole16(S_IFCHR);
216+
wh_ino->i_nlink = htole16(252);
217+
wh_ino->i_u.rdev = htole32(0); /* rdev=0 makes it a whiteout */
218+
219+
/* The loader must reject this image with EINVAL (hardlinked whiteout)
220+
* and must not crash (the original bug was a use-after-free). */
221+
cleanup_node struct lcfs_node_s *root =
222+
lcfs_load_node_from_image(image, sizeof(image));
223+
int errsv = errno;
224+
assert(root == NULL);
225+
assert(errsv == EINVAL);
226+
}
227+
124228
// Verifies that lcfs_fd_measure_fsverity fails on a fd without fsverity
125229
static void test_no_verity(void)
126230
{
@@ -140,9 +244,13 @@ static void test_no_verity(void)
140244

141245
int main(int argc, char **argv)
142246
{
247+
(void)argc;
248+
(void)argv;
249+
143250
test_basic();
144251
test_no_verity();
145252
test_add_uninitialized_child();
146253
test_xattr_addremove();
147254
test_xattr_doubleadd();
255+
test_hardlinked_whiteout_load();
148256
}

0 commit comments

Comments
 (0)