[PATCH AUTOSEL 6.18-5.10] romfs: detect hard link cycles

From: Sasha Levin

Date: Mon Oct 19 2026 - 10:39:43 EST


From: 이상호 <kudo3228@xxxxxxxxx>

[ Upstream commit d30b5a954e0a4dcc16bba9c310c13709a6169a52 ]

romfs_iget() follows on-disk hard link entries until it reaches a non-hard
link inode:

pos = be32_to_cpu(ri.spec) & ROMFH_MASK;

The target position is image-controlled, and the loop does not detect
cycles. A crafted romfs image can make the root inode a hard link. The hard
link can point back to itself and leave mount(2) spinning in the kernel.

Reject excessive hard link indirection with -ELOOP. Normal romfs images do
not need long hard link chains. This bounds corrupted-image traversal.
Propagate romfs_iget() errors from lookup because hard link traversal can
now fail with -ELOOP.

Signed-off-by: 이상호 <kudo3228@xxxxxxxxx>
Link: https://patch.msgid.link/20260701220729.822112-1-kudo3228@xxxxxxxxx
Signed-off-by: Christian Brauner (Amutable) <brauner@xxxxxxxxxx>
Signed-off-by: Sasha Levin <sashal@xxxxxxxxxx>
---

LLM Generated explanations, may be completely bogus:

The local tree is **6.1.188** (`pending-6.1`). The unbounded hard-link
walk is present here; the fix is not.

---

## PHASE 1: COMMIT MESSAGE FORENSICS

**Step 1.1: PARSE THE SUBJECT LINE**

- Subsystem prefix: `romfs:`
- Action verb: `detect` (bug-fix language: find/stop a cycle that
currently is not handled)
- Claimed intent: stop unbounded on-disk hard-link traversal

Record: **[romfs] [detect] [bound hard-link walk so a crafted image
cannot spin forever]**

**Step 1.2: PARSE ALL COMMIT MESSAGE TAGS**

Verified from `d30b5a954e0a4` on `origin/master` (mainline), not from
this 6.1 HEAD:

- `Signed-off-by: 이상호 <kudo3228@xxxxxxxxx>` — author
- `Link:
https://patch.msgid.link/20260701220729.822112-1-kudo3228@xxxxxxxxx` —
original submission
- `Signed-off-by: Christian Brauner (Amutable) <brauner@xxxxxxxxxx>` —
VFS maintainer applied it
- **No** `Fixes:` tag
- **No** `Reported-by:` / syzbot
- **No** `Tested-by:` / `Reviewed-by:` / `Acked-by:`
- **No** `Cc: stable@xxxxxxxxxxxxxxx` (expected for this review
pipeline)

Record: **Author SOB + VFS-maintainer SOB + lore Link. No syzbot, no
Fixes, no stable CC.**

**Step 1.3: ANALYZE THE COMMIT BODY TEXT**

- Bug: `romfs_iget()` follows `ri.spec` while the entry type is
`ROMFH_HRD`. The target is image-controlled. There is no cycle/depth
check.
- Symptom: a crafted image can make the root inode a self-referential
hard link; `mount(2)` never returns (kernel-side infinite loop).
- Fix: cap hard-link hops at 64 and return `-ELOOP`. Also return
`romfs_iget()` errors from lookup so `-ELOOP` is visible to userspace.
- Author: normal images do not need long hard-link chains.

Record: **[crafted-image infinite hard-link walk] [mount hangs in
kernel] [no version bound in message] [missing cycle/depth check on
image-controlled `spec`]**

**Step 1.4: DETECT HIDDEN BUG FIXES**

Not hidden. The subject and body describe a hang. The lookup `IS_ERR`
check is supporting error propagation, not cleanup-for-style.

Record: **Explicit hang fix, not a disguised cleanup.**

---

## PHASE 2: DIFF ANALYSIS

**Step 2.1: INVENTORY THE CHANGES**

Verified via `git show --stat d30b5a954e0a4`: **8 insertions, 0
deletions, one file `fs/romfs/super.c`.**

Functions:
- `romfs_lookup()` — after `romfs_iget()`, return `ERR_CAST(inode)` on
error
- `romfs_iget()` — `depth` counter; abort after
`ROMFS_MAX_HARDLINK_DEPTH` (64)

The provided candidate diff also shows `inode_state_read_once(i)` in
**unchanged context**. That helper does **not** exist in this 6.1 tree
(`grep` found zero hits). The **changed** lines do not use it.

Record: **[+8 / -0, one file] [romfs_lookup, romfs_iget] [single-file
surgical fix]**

**Step 2.2: UNDERSTAND THE CODE FLOW CHANGE**

Hunk 1 (`romfs_lookup`): on name match, optionally one hard-link hop,
then `romfs_iget()`. Before: any `ERR_PTR` was passed to
`d_splice_alias()`. After: lookup returns the error immediately.

Hunk 2 (`romfs_iget`): `for (;;)` reads the header, breaks only when
type is not `ROMFH_HRD`, else sets `pos` from `spec` with no hop limit.
After: 65th hop returns `-ELOOP`.

Affected paths: mount (`romfs_fill_super` → `romfs_iget`) and lookup of
hard-linked names.

Record: **[unbounded HRD walk → depth-capped walk; lookup now fails
closed on iget errors]**

**Step 2.3: IDENTIFY THE BUG MECHANISM**

Category: **logic / correctness plus unbounded iteration** (crafted-
image hang). Not UAF, not a leak.

Mechanism: on-disk `spec` is trusted as the next header. A cycle (or a
long in-image HRD chain) never hits a non-HRD type. `romfs_dev_read()`
only fails on I/O or out-of-image `pos` (`pos >= limit` in
`fs/romfs/storage.c`), so a **valid in-image cycle** never exits.

Record: **[unbounded image-controlled loop] [depth limit + -ELOOP]**

**Step 2.4: ASSESS THE FIX QUALITY**

Obviously correct: same pattern as symlink `MAX_NESTED_LINKS` /
`-ELOOP`. 64 hops is generous (VFS symlink cap in this tree is 8).
Legitimate genromfs images use hard links for `.` / `..` (one hop),
documented in `Documentation/filesystems/romfs.rst`.

Regression risk is low. The lookup `IS_ERR` check is slightly redundant
here because `d_splice_alias()` already does `if (IS_ERR(inode)) return
ERR_CAST(inode);` (`fs/dcache.c:3147`), but it is still correct.

Record: **[minimal, obviously correct] [low regression risk; 64 is well
above real romfs usage]**

---

## PHASE 3: GIT HISTORY INVESTIGATION

**Step 3.1: BLAME THE CHANGED LINES**

`git blame fs/romfs/super.c` around the loop:

- Hard-link walk in `romfs_iget()`: **da4458bda237aa** (David Howells,
2009-02-12), “NOMMU: Make it possible for RomFS to use MTD devices
directly”
- `git describe --contains da4458bda237aa` → **v2.6.30-rc1**
- `git merge-base --is-ancestor da4458bda237aa HEAD` → **yes, in this
tree**

The unbounded loop has been here for the entire 6.1 line.

Record: **[da4458bda237aa / v2.6.30] [buggy walk present in 6.1.188]**

**Step 3.2: FOLLOW THE FIXES: TAG**

No `Fixes:` tag. The introducing commit is identified by blame, not by
the message.

Record: **[No Fixes tag] [introducer da4458bda237aa is in this tree]**

**Step 3.3: CHECK FILE HISTORY FOR RELATED CHANGES**

Path-limited `git log --oneline -- fs/romfs/super.c` on this tree:
recent work is `romfs: check sb_set_blocksize() return value` (already
here), `read_folio`, inode alloc. No prior cycle check.

On `origin/master` after this fix: merge of vfs-7.3 plus unrelated `fs:
add dedicated block device open helpers`. **No follow-up correction** of
this patch.

`40e2c71d57565` (“romfs: fix returm err while getting inode in
fill_super”) already makes `romfs_fill_super()` check `IS_ERR(root)` —
mount will fail with `-ELOOP` once `romfs_iget()` can return it.

Record: **[standalone; no prerequisite series; fill_super already
propagates iget errors]**

**Step 3.4: CHECK THE AUTHOR'S OTHER COMMITS**

`git log --author="kudo3228@xxxxxxxxx"` on this tree: Bluetooth HIDP
payload/header validation. Same author pattern (image/packet bounds),
not the romfs maintainer.

Record: **[contributor, not romfs maintainer] [applied by VFS maintainer
Brauner]**

**Step 3.5: CHECK FOR DEPENDENT/PREREQUISITE COMMITS**

The patch is `[PATCH]` (single), not `n/m`. It only uses `IS_ERR` /
`ERR_PTR` / `ERR_CAST` / `ELOOP`, all present in 6.1. It does **not**
require `inode_state_read_once`.

Record: **[no dependencies] [can apply standalone to this tree]**

---

## PHASE 4: MAILING LIST AND EXTERNAL RESEARCH

**Step 4.1: FIND THE ORIGINAL PATCH DISCUSSION**

`b4 dig -c <sha>` cannot match a commit that is not HEAD. `b4 mbox` on
Message-ID `20260701220729.822112-1-kudo3228@xxxxxxxxx` saved
`/tmp/romfs-hardlink.mbox`: **2 messages**.

1. Submission 2026-07-02 to linux-kernel and linux-fsdevel, subject
`[PATCH] romfs: detect hard link cycles`
2. Christian Brauner 2026-07-03: applied to `vfs-7.3.misc` as
`c4d617e5d28d`

No v2/v3. No NAK. No reviewer-requested respin. Direct-to-maintainer
apply.

Record: **[lore thread via msgid
20260701220729.822112-1-kudo3228@xxxxxxxxx] [single revision] [applied
by Brauner] [no NAK]**

**Step 4.2: CHECK WHO REVIEWED THE PATCH**

Original To/Cc: `linux-kernel@xxxxxxxxxxxxxxx`, `linux-
fsdevel@xxxxxxxxxxxxxxx`. No extra reviewer list. Brauner applied it
(VFS maintainer per `MAINTAINERS`: FILESYSTEMS (VFS and
infrastructure)).

Record: **[linux-fsdevel + VFS maintainer apply; no Reviewed-by
trailers]**

**Step 4.3: SEARCH FOR THE BUG REPORT**

No `Reported-by` or bugzilla/syzbot link. The hang is reasoned from the
code, not from a public crash report.

lore.kernel.org HTTP fetches returned **403**; discussion was recovered
via `b4 mbox`.

Record: **[no public bug report] [author describes mount hang from
crafted root HRD cycle]**

**Step 4.4: CHECK FOR RELATED PATCHES AND SERIES**

Standalone `[PATCH] 1/1`. No related romfs cycle patches in the mbox or
in `git log origin/master -- fs/romfs/super.c` after it.

Record: **[standalone, not part of a multi-patch series]**

**Step 4.5: CHECK STABLE MAILING LIST HISTORY**

lore.kernel.org/stable search returned 403. `git log origin/master
--grep="detect hard link cycles" -- fs/romfs/super.c` finds only the
mainline commit. No evidence of a stable NAK.

Record: **[no stable-list discussion recovered; UNVERIFIED beyond 403 on
lore/stable]**

---

## PHASE 5: CODE SEMANTIC ANALYSIS

**Step 5.1: KEY FUNCTIONS**

`romfs_lookup()`, `romfs_iget()`. New macro `ROMFS_MAX_HARDLINK_DEPTH`.

**Step 5.2: TRACE CALLERS**

`git grep romfs_iget` in `fs/romfs/super.c`:

- `romfs_lookup()` — directory lookup (`->lookup`)
- `romfs_fill_super()` line 536 — get root inode during mount

`romfs_lookup` is `romfs_dir_inode_operations.lookup`.

**Step 5.3: TRACE CALLEES**

`romfs_iget` calls `romfs_dev_read`, `romfs_dev_strnlen`, `iget_locked`.
`romfs_dev_read` rejects `pos >= romfs_maxsize(sb)` with `-EIO`, so
**out-of-image** pointers stop; **in-image cycles** do not.

**Step 5.4: FOLLOW THE CALL CHAIN**

`mount("romfs")` → `romfs_get_tree` → `romfs_fill_super` →
`romfs_iget(root)`. If that root header is `ROMFH_HRD` pointing at
itself, the syscall never returns.

Lookup of a cyclic hard link after a successful mount has the same hang
in `romfs_iget`.

`romfs_fs_type.fs_flags = FS_REQUIRES_DEV` only — **no
`FS_USERNS_MOUNT`**. Mount needs `CAP_SYS_ADMIN` (or equivalent). Still
reachable from `mount(2)` on a loop/MTD/block image.

Record: **[reachable from mount and lookup] [needs mount privilege, not
unprivileged userns]**

**Step 5.5: SEARCH FOR SIMILAR PATTERNS**

No `ROMFS_MAX_HARDLINK` elsewhere. VFS uses `MAX_NESTED_LINKS = 8` in
`include/linux/namei.h`. SMB client uses the same `-ELOOP` hop bound.
This is the standard “bound image-controlled indirection” pattern.

Romfs docs (`Documentation/filesystems/romfs.rst`) say it is the **image
creator’s** job not to make hard-link loops. The kernel still must not
spin if they do.

Record: **[same class as VFS ELOOP bounds; romfs previously trusted the
image]**

---

## PHASE 6: CROSS-REFERENCING AGAINST THE LOCAL TREE

**Step 6.1: DOES THE BUGGY CODE EXIST IN THIS TREE?**

Yes. Local `fs/romfs/super.c`:

```291:303:fs/romfs/super.c
for (;;) {
ret = romfs_dev_read(sb, pos, &ri, sizeof(ri));
if (ret < 0)
goto error;
nextfh = be32_to_cpu(ri.next);
if ((nextfh & ROMFH_TYPE) != ROMFH_HRD)
break;
pos = be32_to_cpu(ri.spec) & ROMFH_MASK;
}
```

No `ROMFS_MAX_HARDLINK_DEPTH`. `git merge-base --is-ancestor
d30b5a954e0a4 HEAD` is false. Introduced in v2.6.30, still here.

Record: **[yes, romfs_iget HRD loop] [since v2.6.30]**

**Step 6.2: CHECK FOR BACKPORT COMPLICATIONS**

Local `romfs_lookup` / `romfs_iget` match the patch hunks. The only
mainline-only context is `inode_state_read_once`, which this patch does
not change. 6.1 still uses `i->i_state & I_NEW`.

Record: **[clean apply expected; no `inode_state_read_once`
dependency]**

**Step 6.3: CHECK IF RELATED FIXES ARE ALREADY HERE**

No cycle detection. Related: fill_super already returns `PTR_ERR(root)`
(`40e2c71d57565`). Another crafted-image romfs fix (`sb_set_blocksize`)
is already in this 6.1 tree — same class of image-validation bug.

Record: **[no existing cycle fix] [fill_super error path ready]**

---

## PHASE 7: SUBSYSTEM AND MAINTAINER CONTEXT

**Step 7.1: SUBSYSTEM AND CRITICALITY**

`fs/romfs/` — small read-only FS for initrd/embedded/MTD. **PERIPHERAL**
relative to ext4/VFS, but it is a real in-tree filesystem used on 6.1
devices that enable `CONFIG_ROMFS_FS`.

**Step 7.2: SUBSYSTEM ACTIVITY**

Low churn. The HRD walk has been stable since 2009. That makes the hang
long-lived, not a new-subsystem mismatch.

Record: **[romfs] [PERIPHERAL but long-present] [mature, little recent
change]**

---

## PHASE 8: IMPACT AND RISK ASSESSMENT

**Step 8.1: WHO IS AFFECTED**

Users who mount romfs (`CONFIG_ROMFS_FS`) from block or MTD. Not
universal. Includes initrd, embedded, and anyone mounting an untrusted
image (USB, loop).

**Step 8.2: TRIGGER CONDITIONS**

- Crafted image: root header type `ROMFH_HRD`, `spec` pointing at itself
(or a cycle).
- Or lookup of a cyclic hard-link directory entry.
- Not every boot; only bad/malicious images.
- Unprivileged users cannot mount romfs in a user namespace
(`FS_REQUIRES_DEV` only). Admins, installers, and fuzzers can.

**Step 8.3: FAILURE MODE SEVERITY**

Infinite loop in `romfs_iget` during `mount(2)` or lookup. After the
first header is cached, `romfs_dev_read` is a tight CPU loop → hung task
/ soft lockup, one CPU stuck in kernel, mount never completes.

Record: **[kernel hang / soft lockup] [HIGH to CRITICAL for
CONFIG_ROMFS_FS; local DoS with mount cap]**

**Step 8.4: RISK-BENEFIT RATIO**

- Benefit: stops a real hang on corrupted/malicious romfs images; 8
lines; same validation class already accepted in this tree
(`sb_set_blocksize`).
- Risk: very low; depth 64 will not break genromfs images.

Record: **[high benefit for romfs users] [very low risk] [favor
backport]**

---

## PHASE 9: FINAL SYNTHESIS

**Step 9.1: EVIDENCE**

FOR:
- Real unbounded loop; image-controlled `spec`; self-loop hangs
`mount(2)`
- Code is in this 6.1 tree since v2.6.30
- 8-line, one-file, obviously correct `-ELOOP` bound
- Applied by VFS maintainer; no NAK; no later revert/fix on mainline
- fill_super already handles `IS_ERR` from `romfs_iget`
- Applies without `inode_state_read_once`
- Important: hang/soft lockup, not a style tweak

AGAINST:
- romfs is niche
- Needs mount privilege (not unprivileged userns)
- No syzbot/Reported-by/Tested-by
- Lookup `IS_ERR` is redundant with `d_splice_alias` in 6.1

UNRESOLVED:
- lore.kernel.org HTTP 403; stable-list search not readable via WebFetch
(mbox via b4 succeeded for the fsdevel thread)

**Step 9.2: STABLE RULES CHECKLIST**

1. Obviously correct and tested? **PASS** — trivial depth bound; applied
by Brauner. Testing is implied by mainline merge, not by a Tested-by.
2. Fixes a real bug that affects users? **PASS** — crafted/corrupt romfs
hangs mount/lookup.
3. Important (crash, security, corruption, deadlock)? **PASS** — kernel
hang / soft lockup (local DoS).
4. Small and contained? **PASS** — 8 lines, one file.
5. No new features or APIs? **PASS**.
6. Can apply to this tree? **PASS** — buggy code present; hunks match
6.1 aside from unrelated `inode_state_read_once` context.

**Step 9.3: EXCEPTION CATEGORIES**

None of device-ID / quirk / DT / build / docs. Ordinary small hang fix.

**Step 9.4: DECISION**

This is a small, correct bound on image-controlled iteration that
prevents a kernel hang on 6.1. Privilege to mount does not make an
infinite kernel loop acceptable for stable. Backport it.

---

### What the commit solves for this tree

`romfs_iget()` walks on-disk hard links with no hop limit. A crafted
image can make that walk a cycle, including at the root inode, so
`mount(2)` spins in the kernel. 6.1.188 still has that loop. Returning
`-ELOOP` after 64 hops stops the hang; lookup error propagation is a
small companion change.

### Risk vs benefit

Benefit is avoiding a hung task on bad romfs images. Risk is near zero:
legitimate images do not need deep HRD chains, and the patch does not
change data structures or APIs.

### Concerns

None material. Do not port `inode_state_read_once`; it is not part of
the fix. No prerequisite commits.

---

### Verification

- **[Phase 1]** `git describe HEAD` → `p-6.1-11-g10bc6eb33d95f`; `make
kernelversion` → **6.1.188**
- **[Phase 1]** `git log origin/master -1 d30b5a954e0a4` — tags: author
SOB, Brauner SOB, msgid Link; no Fixes/Reported-by/Cc:stable
- **[Phase 2]** `git show --stat d30b5a954e0a4` — `fs/romfs/super.c` +8
- **[Phase 2]** Read local `romfs_iget` / `romfs_lookup` /
`romfs_fill_super` / `romfs_dev_read` — unbounded HRD loop; out-of-
image reads fail; in-image cycles do not
- **[Phase 2]** `d_splice_alias` in this tree already handles
`IS_ERR(inode)` (`fs/dcache.c:3147`)
- **[Phase 3]** `git blame` — HRD walk from **da4458bda237aa**
(v2.6.30); `git merge-base --is-ancestor da4458bda237aa HEAD` true
- **[Phase 3]** `git merge-base --is-ancestor d30b5a954e0a4 HEAD` false
— fix **not** in this tree
- **[Phase 3]** `grep ROMFS_MAX_HARDLINK fs/romfs/super.c` — not present
- **[Phase 3]** `git log origin/master d30b5a954e0a4^..origin/master --
fs/romfs/super.c` — no follow-up fix
- **[Phase 3]** `grep inode_state_read_once` in this tree — **zero
hits**
- **[Phase 4]** `b4 mbox` msgid
`20260701220729.822112-1-kudo3228@xxxxxxxxx` — 2-message thread;
Brauner applied to vfs-7.3.misc
- **[Phase 4]** WebFetch lore.kernel.org / patch.msgid.link /
git.kernel.org → **403** / bot page
- **[Phase 5]** Callers of `romfs_iget`: fill_super (mount) and lookup;
`fs_flags = FS_REQUIRES_DEV` only
- **[Phase 5]** Docs: hard-link loops called out as image-creator
responsibility, not kernel-enforced
- **[Phase 6]** Local loop at `fs/romfs/super.c:291-303` matches the
pre-patch code
- **[Phase 8]** Failure mode: infinite loop in kernel on mount/lookup of
cyclic HRD
- **UNVERIFIED:** stable@ mailing-list archive (HTTP 403); no public
crash ID beyond the commit text

**YES**

fs/romfs/super.c | 8 ++++++++
1 file changed, 8 insertions(+)

diff --git a/fs/romfs/super.c b/fs/romfs/super.c
index e83f9b78d7a16..9b997831a46bc 100644
--- a/fs/romfs/super.c
+++ b/fs/romfs/super.c
@@ -240,6 +240,8 @@ static struct dentry *romfs_lookup(struct inode *dir, struct dentry *dentry,
if ((be32_to_cpu(ri.next) & ROMFH_TYPE) == ROMFH_HRD)
offset = be32_to_cpu(ri.spec) & ROMFH_MASK;
inode = romfs_iget(dir->i_sb, offset);
+ if (IS_ERR(inode))
+ return ERR_CAST(inode);
break;
}

@@ -262,6 +264,8 @@ static const struct inode_operations romfs_dir_inode_operations = {
.lookup = romfs_lookup,
};

+#define ROMFS_MAX_HARDLINK_DEPTH 64
+
/*
* get a romfs inode based on its position in the image (which doubles as the
* inode number)
@@ -273,6 +277,7 @@ static struct inode *romfs_iget(struct super_block *sb, unsigned long pos)
struct inode *i;
unsigned long nlen;
unsigned nextfh;
+ unsigned int depth = 0;
int ret;
umode_t mode;

@@ -289,6 +294,9 @@ static struct inode *romfs_iget(struct super_block *sb, unsigned long pos)
if ((nextfh & ROMFH_TYPE) != ROMFH_HRD)
break;

+ if (++depth > ROMFS_MAX_HARDLINK_DEPTH)
+ return ERR_PTR(-ELOOP);
+
pos = be32_to_cpu(ri.spec) & ROMFH_MASK;
}

--
2.53.0