XMR gang
active // 2026
← back
high unpatched bentley vw group
Broken Access Control & Auth Info Leak on id.bentleymotors.com
security misconfiguration & session misconfiguration on id.bentleymotors.com / id.vwgroup.io
tl;dr Stumbled onto Bentley's identity platform while doing recon and realized it was actually VW Group shared infra. The internal app shell was being served unauthenticated on a route NGINX forgot to lock down, and failed auth attempts were leaking session cookies and internal header info. Reported it. Still unpatched as of writing.
01 the recon

Was doing some passive recon on bentleymotors.com and stumbled onto a subdomain I hadn't seen before, id.bentleymotors.com. Running NGINX, clearly some kind of identity / login portal. Looked custom-built, which is always interesting because custom auth flows tend to have weird edges.

The first thing that caught my eye was the CSP header. Buried in img-src there was a wildcard pointing at https://*.vwgroup.io. That's a pretty strong signal this isn't actually a Bentley-owned stack. It's VW Group's shared "Identity Kit" platform that Bentley just happens to be a tenant on. Filed that away for later because it ends up mattering.

02 the landing page that shouldn't have loaded

Started mapping routes. Most of them behaved. Hit /api/profile unauthenticated, got bounced with a 401, no big surprise. Then I tried /landing-page.

$ curl -s -w "\nStatus: %{http_code}\n" https://id.bentleymotors.com/landing-page # Returns: Status: 200 (Dumps internal app HTML/JS)

200 OK. Full internal application shell. HTML, JS routing, the API map, all of it, served to me with no session, no token, nothing. The frontend auth guard was clearly doing its job client-side, but the server wasn't enforcing anything on this route. Classic case of trusting the SPA to handle auth.

Remembering the VW Group CSP tie-in, I pointed the same request at id.vwgroup.io. Identical behavior, same route, same 200, same dump.

$ curl -s -w "\nStatus: %{http_code}\n" https://id.vwgroup.io/landing-page # Returns: Status: 200 (Dumps internal app HTML/JS)

So this isn't a Bentley misconfiguration. It's a misconfiguration in the underlying Identity Kit platform that every VW Group tenant is inheriting. That raised the scope question real fast.

03 the cookie that shouldn't exist

While poking at authenticated routes, I noticed something weirder. Hit the OAuth callback with a bogus redirect:

$ curl -s -I "https://id.bentleymotors.com/authorized/callback?redirect_uri=https://tester.com" HTTP/2 401 set-cookie: identity-kit-profile-session=[TOKEN]; Expires=...; HttpOnly www-authenticate: Bearer error="Missing access token in cookies" content-security-policy: ... img-src 'self' blob: data: https://*.vwgroup.io; ...

Two things wrong here. First, the www-authenticate header is telling me exactly where the app expects the access token to live ("in cookies"). That's free intel for an attacker, you don't have to guess where to stuff a stolen token.

Second, and this is the weird one, a failed 401 auth attempt is mutating state. The server is issuing a valid session cookie (identity-kit-profile-session) on a request that just got rejected. Error responses shouldn't be writing session state. At minimum it's sloppy, at worst it could be abused depending on what downstream code trusts that cookie's presence.

04 stopping point

The moment I confirmed this was VW Group shared infrastructure and not just Bentley, I stopped. Bentley's bug bounty scope is Bentley's IT systems, and going further on a platform that handles identity for the entire VW Group would've blown past that line. Reported both findings to Bentley, noted the VW Group tie-in, and let them handle escalation internally.

As of writing, both issues are still unpatched. The landing page route still serves the internal app shell to unauthenticated users, and the 401 response still leaks the session cookie and the access token location.

← back
critical patched kernel overlayfs containers
OverlayFS nop_mnt_idmap Bug
container escape / LPE via idmapped mount bypass in fs/overlayfs/inode.c
tl;dr OverlayFS takes an idmap argument from the VFS for setattr, permission, and set_acl calls, but ignores it and passes nop_mnt_idmap (identity mapping) instead. Inside a container with user namespaces + idmapped mounts, this means container UID 0 gets treated as host UID 0 during OverlayFS operations. Container escape, no setuid needed. Reported and patched upstream.
01 the setup

Was hunting for LPE primitives that don't need setuid. Spent a while on race conditions in the VFS layer, kept hitting dead ends, and pivoted to logic bugs in the idmapping chain. The idmapped mount feature is relatively new and the interaction with OverlayFS felt underexplored, so I started reading fs/overlayfs/inode.c.

The setup that matters: a host running a container with a user namespace, where container UID 0 maps to something like host UID 100000. Inside that container, you mount an idmapped mount that does the UID translation, and then you stack OverlayFS on top of it. The lower layer is the idmapped mount, the upper layer is container-local. The kernel is supposed to use the idmap for every permission check on the OverlayFS files so the container's UID 0 never gets confused with host UID 0.

02 the bug

The functions in inode.c receive the correct idmap argument from the VFS, then throw it away and use &nop_mnt_idmap instead. nop_mnt_idmap is the identity mapping, meaning no UID/GID translation happens at all.

// fs/overlayfs/inode.c int ovl_setattr(struct mnt_idmap *idmap, struct dentry *dentry, struct iattr *attr) { // BUG: receives idmap, ignores it, uses identity mapping err = setattr_prepare(&nop_mnt_idmap, dentry, attr); // WRONG // should be: setattr_prepare(idmap, dentry, attr); } int ovl_permission(struct mnt_idmap *idmap, struct inode *inode, int mask) { err = generic_permission(&nop_mnt_idmap, inode, mask); // WRONG } // ovl_set_acl() lines 537 and 546 are also affected

So every permission check on OverlayFS files goes through the identity map. Container UID 0, which the host is supposed to translate to UID 100000, never gets translated. It's just UID 0 all the way down.

03 the primitive

The attack chain is straightforward once the bug is clear. Inside the container, you create a file through the OverlayFS mount. Then you chmod or chown it. Because ovl_setattr uses nop_mnt_idmap, the ownership change happens in the host's UID space directly, with no translation. Container UID 0 becomes host UID 0. The permission check passes because, as far as the kernel is concerned, you ARE UID 0 on the host.

HOST (UID 0) └── container namespace (UID 0 → host UID 100000) └── idmapped mount (/idmapped, with mapping) └── OverlayFS mounted on top └── lowerdir=/idmapped, upperdir=... attack: 1. container process creates a file via OverlayFS 2. calls chmod / chown on the file 3. ovl_setattr() uses nop_mnt_idmap (identity) 4. container UID 0 treated as host UID 0 5. permission check passes incorrectly 6. result: container has access to host files

No setuid file involved. No race condition. No kernel address leak needed. The whole thing is a clean logic bug.

A minimal PoC looks like this:

// run inside a container with a user namespace + idmapped mount + overlayfs int main(void) { int fd = open("/overlay/test", O_CREAT|O_WRONLY, 0644); close(fd); // should go through the idmap, but doesn't chown("/overlay/test", 0, 0); // UID 0 in container // file now has UID 0 on the host. escaped. return 0; }
04 other stuff I noticed while in there

While auditing around the same area I found a handful of other permission gaps. None of them are as clean as the OverlayFS one, but they're worth noting.

#2 lookup_noperm skips permission checks fs/namei.c:3086-3382

lookup_noperm and friends do a dcache lookup with no inode_permission() call. They're exported to filesystem modules. A malicious or buggy module can use them to walk paths without DAC checks. Severity is high but you need a filesystem module, which limits the attack surface.

#3 O_PATH skips LSM hooks fs/open.c:898-903

Opening a file with O_PATH sets f_op = &empty_fops and returns early, before security_file_open() and fsnotify_open_perm() get called. You can then reopen the fd via /proc/self/fd/ and the LSM never sees the original open. Medium severity, but useful for flying under monitoring.

#4 xattr security.* and system.* skip DAC fs/xattr.c:120-138

Setting xattrs with the security. or system. prefix skips the inode_permission() call entirely. You can write security xattrs without proper DAC checks. Medium severity.

#5 ATTR_FORCE bypasses all permission checks fs/attr.c:187-189

If ATTR_FORCE is set in the iattr mask, the code jumps straight to kill_priv and skips every permission check. Internal callers or buggy filesystems can use this to bypass chown/chmod checks entirely. Medium, internal-triggered.

05 stuff I haven't fully verified

Two more threads I want to pull on but haven't confirmed yet.

FUSE as OverlayFS lower layer. If the kernel allows a FUSE filesystem as the lower layer of an OverlayFS mount, you could craft a FUSE fs that returns files owned by the attacker with capability xattrs set. When OverlayFS does copy-up, the upper layer ends up with attacker-owned files with capabilities. That would be critical if it works. I haven't verified whether the kernel actually allows FUSE as a lower layer yet.

Idmapped mount edge cases. The INVALID_UID fallback (UID 65534 / nobody) when mapping fails could grant access to files owned by unmapped UIDs. There's also a potential capability namespace mismatch in capable_wrt_inode_uidgid(), which uses mnt_userns while the inode UID might be in a different namespace. Nested namespaces with overlapping mappings could cause privilege confusion. Both need verification.

06 priority + status
vuln sev exploitable no setuid priority
nop_mnt_idmap critical yes yes #1
FUSE lower layer critical tbd yes #2
lookup_noperm high needs module yes #3
idmapped mount bugs high tbd yes #4
O_PATH bypass med yes yes #5
xattr DAC skip med yes yes #6
ATTR_FORCE bypass med internal yes #7

The fix for the main bug is a one-liner per call site. Pass the idmap that was already passed in.

--- a/fs/overlayfs/inode.c +++ b/fs/overlayfs/inode.c @@ -29,7 +29,7 @@ int ovl_setattr(struct mnt_idmap *idmap, struct dentry *dentry, bool full_copy_up = false; struct dentry *upperdentry; - err = setattr_prepare(&nop_mnt_idmap, dentry, attr); + err = setattr_prepare(idmap, dentry, attr); if (err) return err;

Same fix in ovl_permission() at line 309 and ovl_set_acl() at lines 537 and 546. Reported and patched upstream. The fix is exactly what you'd expect, pass the idmap that was already passed in.

← back
high unpatched kernel futex lpe
Robust List Arbitrary Address Write
signed futex_offset with no validation in kernel/futex/core.c
tl;dr When a thread exits, the kernel walks its robust futex list and calls handle_futex_death() on each entry. The address it operates on is computed as entry + futex_offset, where futex_offset is a signed 64-bit long read from userspace with zero validation. You can point it at any userspace address. The kernel will read from that address and, if a TID condition matches, set bit 30 on it. Arbitrary address read plus a conditional single-bit write primitive, no setuid needed.
01 the recon

Was looking at the futex subsystem on Linux 6.12.74+deb13+1-amd64. Originally chasing race conditions in the private hash code, but CONFIG_FUTEX_PRIVATE_HASH isn't compiled into this kernel and the FUTEX2_NUMA TOCTOU path isn't implemented. Dead ends. Pivoted to the robust futex list, which is older code and gets walked every time a thread exits.

The relevant path is exit_robust_list() calling into handle_futex_death(). The list head lives in userspace. The kernel reads it, walks the entries, and for each entry computes the futex address as entry + futex_offset.

02 the offset bug

futex_offset is a signed 64-bit long read straight from userspace with get_user. No bounds check, no VMA validation, no magnitude check, no verification that the computed address is inside the process's own memory. Positive offset goes forward from entry, negative offset goes backward, full 64-bit range.

// kernel/futex/core.c, exit_robust_list() if (get_user(futex_offset, &head->futex_offset)) // signed long, no bounds check return; // later, computed address passed straight to handle_futex_death() handle_futex_death((void __user *)entry + futex_offset, curr, pi, ...);

The compat path (compat_exit_robust_list()) has the same bug, with the added weirdness that 32-bit compat mode truncates pointers, so the address calculation can land somewhere unexpected. Same root cause, slightly different shape.

03 the primitive

Inside handle_futex_death(), the kernel does a get_user on the computed address. That's an arbitrary userspace read. Then it masks the value with FUTEX_TID_MASK (0x3fffffff) and checks whether it equals the dying thread's TID. If it matches, it does a cmpxchg that sets bit 30 (FUTEX_OWNER_DIED).

// handle_futex_death() if (get_user(uval, uaddr)) return -1; owner = uval & FUTEX_TID_MASK; // 0x3fffffff if (owner != task_pid_vnr(curr)) return 0; // conditional write to arbitrary address mval = (uval & FUTEX_WAITERS) | FUTEX_OWNER_DIED; // sets bit 30 futex_cmpxchg_value_locked(&nval, uaddr, uval, mval);

So the primitive is: read any userspace address, and if you can place the dying thread's TID in the low 30 bits at that address, set bit 30 on it. There's also a secondary path: if FUTEX_WAITERS (bit 31) is set and it's not a PI futex, the kernel calls futex_wake() on the arbitrary address. That gives you a cross-process futex wake primitive on addresses the dying task never actually held.

capability details
read arbitrary userspace read via get_user()
write condition (value & 0x3fffffff) == dying_thread_tid
write effect value = (value & 0x80000000) | 0x40000000
bit modified bit 30 (OWNER_DIED)
04 what you can actually do with it

A few scenarios that look interesting.

Cross-process futex wake. Set futex_offset to point at another process's futex word. Place the dying thread's TID there. On exit, the kernel sets OWNER_DIED and triggers futex_wake() on that address. You've now woken waiters on a futex the dying task never held. State confusion in the target process, potentially useful for locking bugs.

Info leak via fault handling. The fault_in_user_writeable() path calls fixup_user_fault() with FAULT_FLAG_WRITE. That can allocate new pages during the exit path and may trigger COW on read-only mappings. Page table modifications during exit are inherently sketchy.

setuid state confusion. Find a setuid binary that uses robust futexes. Manipulate the robust list before exec(). When the thread exits, the kernel writes OWNER_DIED to a controlled address. Confuse the setuid binary's locking state. This is the most interesting one for actual privilege escalation but needs the right target binary.

05 where it stops working

The primitive has real limits. The write only fires if the value at the target address has the dying thread's TID in the low 30 bits, but since you control the memory layout you can just place the right TID there. The write only sets bit 30, so it's a single-bit primitive, not a full write. Kernel addresses fault, so you're stuck in userspace. And ROBUST_LIST_LIMIT caps you at 2048 iterations, which limits how many addresses you can hit in one exit.

limitation impact
must match TID attacker controls memory, can place correct TID
only sets bit 30 limited write primitive
needs valid userspace addr kernel addresses will fault
ROBUST_LIST_LIMIT=2048 limited iterations per exit
06 PoC concept

The shape of the exploit is simple. mmap a target page, set up a single-entry robust list where futex_offset is calculated so that entry + futex_offset lands on the target. Place the current PID at the target so the TID check passes. Register the robust list with set_robust_list. Exit. The kernel walks the list, reads the target, matches the TID, sets bit 30.

struct robust_list_head { struct robust_list *list; long futex_offset; struct robust_list *list_op_pending; }; int main(void) { // target address we want to write to uint32_t *target = mmap(NULL, 0x1000, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0); struct robust_list *entry = malloc(sizeof(*entry)); entry->next = entry; // self-referential, single entry // uaddr = entry + futex_offset = target long futex_offset = (long)target - (long)entry; struct robust_list_head head; head.list.next = entry; head.futex_offset = futex_offset; // arbitrary offset head.list_op_pending = NULL; syscall(__NR_set_robust_list, &head, sizeof(head)); // place current TID at target so the condition matches *target = getpid(); // exit triggers exit_robust_list() // if (*target & 0x3fffffff) == getpid(): // *target = (*target & 0x80000000) | 0x40000000 return 0; }

Next step is finding a kernel structure where flipping bit 30 actually means something. Permission bits, lock state bits, refcount bits if bit 30 happens to be significant. That's the hard part and it's where the real work is. The primitive itself is confirmed.

07 status

As of the kernel I tested, none of the four missing checks (no bounds on futex_offset, no VMA validation, no process boundary check, no max magnitude check) are present in the code. The bug is unpatched. Coordinating disclosure.

XMRgang
Advanced Persistent Autistic Jihadist Threat Actor Group. Independent security research focused on low-level exploitation, kernel attack surface, and authentication infrastructure. No clients, no consulting, just findings.
[ 00 ] disclosures
more incoming.