View Issue Details

IDProjectCategoryView StatusLast Update
0006639unrealjson-rpcpublic2026-08-17 15:53
Reporteraramosf Assigned Tosyzop  
PriorityhighSeveritymajorReproducibilityalways
Status resolvedResolutionfixed 
Product Version6.2.6 
Fixed in Version6.2.7 
Summary0006639: UnrealIRCd JSON-RPC Timer Use-After-Free
Description| **Title** | Use-after-free in UnrealIRCd JSON-RPC timer event loop (`rpc_do_timers`) |
| **Author** | A. Ramos <[email protected] |
| **CVE** | Not assigned (pre-disclosure) |
| **CWE** | **CWE-416** — Use After Free |
| **CVSS v4.0** | **6.9 (Medium)** — `CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:L/VA:H/SC:N/SI:N/SA:N` |
| **Affected** | UnrealIRCd **6.2.6** (confirmed); earlier 6.x releases shipping `rpc.add_timer`/`rpc.del_timer` likely affected |
| **Discovered** | 2026-07-26, internal security research (static audit + fuzzing + gdb exploitation analysis) |
| **Vendor** | UnrealIRCd Team — https://www.unrealircd.org |

---

## 1. Summary

The JSON-RPC timer feature of UnrealIRCd (`rpc.add_timer`) lets an
authenticated RPC user schedule an arbitrary JSON-RPC subrequest to be
executed periodically by the server event loop. The event handler that
iterates the timer list, `rpc_do_timers()`, prefetches the next list element
and then executes the current timer's subrequest **synchronously**. If that
subrequest is `rpc.del_timer` naming the timer that sits at the prefetched
next position, the next element is freed mid-iteration and the loop
continues to read from — and execute through — the freed `RPCTimer`
structure.

This is a remotely triggerable, fully deterministic heap use-after-free.
It requires valid RPC credentials (`rpc-user` whose `rpc-class` permits
`rpc.add_timer` and `rpc.del_timer`; the built-in `full` class does).

## 2. Root cause

`src/modules/rpc/rpc.c`:

```c
EVENT(rpc_do_timers)
{
    RPCTimer *e, *e_next;

    for (e = rpc_timer_list; e; e = e_next)
    {
        e_next = e->next; /* (1) prefetch */
        if (minimum_msec_since_last_run(&e->last_run, e->every_msec))
        {
            rpc_call_json(e->client, e->request); /* (2) may free e_next */
        }
    }
}

void free_rpc_timer(RPCTimer *r)
{
    safe_free(r->timer_id);
    json_decref(r->request);
    DelListItem(r, rpc_timer_list);
    safe_free(r); /* 64-byte heap chunk */
}
```

In step (2), the timer's stored subrequest is dispatched to *any* JSON-RPC
method, synchronously. When the subrequest is
`{"method":"rpc.del_timer","params":{"timer_id":"<next timer>"}}`,
`free_rpc_timer()` releases the `RPCTimer` that `e_next` points to. The loop
then:

- reads `e->next` from freed memory (line 2281),
- reads `e->every_msec` and writes `e->last_run` in freed memory (line 2282),
- calls `rpc_call_json(e->client, e->request)` with dangling `Client *` and
  `json_t *` pointers (line 2284).

List ordering makes the trigger deterministic: `AddListItem()` inserts at
the head of the list (`src/list.c`), so creating timer **B** first and timer
**A** second yields `rpc_timer_list = [A, B]`. With both timers at
`every_msec = 250` (`RPC_MINIMUM_TIMER_MSEC`), both are due in the same
event pass: A fires first and deletes B (`== e_next`).

Note: timers are tied to the RPC client connection and are destroyed on
disconnect (`rpc_handle_free_client`), so the attack requires a persistent
RPC transport, i.e. **JSON-RPC over WebSocket** (`wss://<host>:<port>/api`);
plain HTTP POST closes the connection and deletes the timers before the
event fires.


## 4. Impact

- **Instrumented / debug builds (ASan, allocator debugging):** deterministic
  remote crash of the daemon — **denial of service**.
- **Production builds (glibc tcache, default):** silent heap corruption. The
  event loop executes `rpc_call_json()` once through a freed `RPCTimer`
  whose `client`/`request` fields remain intact. Observed as a "zombie"
  invocation of the freed timer's stored request.
- **Other allocators / hardened builds (no tcache, musl, jemalloc,
  `GLIBC_TUNABLES=glibc.malloc.tcache_count=0`):** the freed chunk is
  immediately reused by the `_rpc_response` `json_object()` allocation, and
  the loop dereferences a corrupted next pointer → remote crash.
- **Code execution:** not demonstrated. Weaponization would require an
  attacker-influenced allocation of the 64-byte size class inside the
  micro-window between `free_rpc_timer()` and the loop's reuse, plus an
  information leak; neither primitive is currently available in the RPC
  response path (empirically verified with an `LD_PRELOAD` malloc
  interposer). The bug class (type confusion over `Client *` and
  `json_t *`) nevertheless makes future exploitability plausible if the
  allocation profile of the RPC code changes.

## 5. CVSS v4.0 scoring rationale

`CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:L/VA:H/SC:N/SI:N/SA:N` — **6.9**

| Metric | Value | Justification |
|---|---|---|
| AV | Network | Triggered over the JSON-RPC listener (TCP/TLS/WebSocket) |
| AC | Low | Fully deterministic trigger; no race or special heap state needed |
| AT | None | No special conditions beyond a standard config with an rpc-user |
| PR | Low | Requires valid `rpc-user` credentials (not IRC operator, not unauthenticated) |
| UI | None | No user interaction |
| VC | None | No data disclosure demonstrated |
| VI | Low | Memory corruption with limited integrity effect (zombie execution from freed heap); no demonstrated control of contents |
| VA | High | Daemon crash (DoS) on instrumented builds and non-tcache allocators |
| SC/SI/SA | None | No demonstrated subsequent-system impact |

*Note:* scores as Medium under the demonstrated impact. Should arbitrary
code execution be demonstrated in the future, re-score with VC/VI:H and
possible scope change (est. 8.7+).

## 6. Affected versions / prerequisites / scope

- Confirmed on **UnrealIRCd 6.2.6** (the latest stable release at analysis
  time — the only version the vendor security policy supports).
- Any 6.x version exposing `rpc.add_timer`/`rpc.del_timer` is expected to be
  affected.
- Prerequisites: an `rpc-user { }` block and a reachable RPC listener
  (`listen { ... options { rpc; } }`), plus a persistent transport
  (JSON-RPC over WebSocket).
- **Scope note (per the vendor policy):** the policy's primary scope covers
  issues triggerable by *regular, unauthenticated* users; this issue
  requires *authenticated RPC credentials*, similar to the "IRCOp rights"
  category, which the policy evaluates **case-by-case**. It is reported
  privately anyway, as the policy recommends ("better safe than sorry"): it
  is a genuine memory-safety violation (CWE-416) reachable over the network,
  with daemon-crash impact on instrumented builds and allocator-dependent
  impact in production.

## 7. Proposed fix

Defer physical freeing while the event iterates (the "dead list" pattern
already used elsewhere in the codebase):

```c
EVENT(rpc_do_timers)
{
    RPCTimer *e, *e_next;
    rpc_timers_iterating++;
    for (e = rpc_timer_list; e; e = e_next)
    {
        e_next = e->next;
        if (e->deleted)
            continue;
        if (minimum_msec_since_last_run(&e->last_run, e->every_msec))
            rpc_call_json(e->client, e->request);
    }
    if (--rpc_timers_iterating == 0)
        sweep_deleted_rpc_timers(); /* real free happens here */
}

void free_rpc_timer(RPCTimer *r)
{
    if (rpc_timers_iterating) {
        r->deleted = 1;
        DelListItem(r, rpc_timer_list);
        AddListItem(r, rpc_timer_deadlist);
        return;
    }
    /* ... existing free path ... */
}
```

The same treatment should cover the self-deletion case
(`rpc.del_timer` naming the timer's own id), which frees the `json_t`
request currently being handled (analyzed as a second, related lifetime
bug).



Steps To Reproduce## 3. Reproduction (PoC)

*Per the UnrealIRCd security policy for tool/AI-assisted reports: this issue
was reproduced on a locally running UnrealIRCd 6.2.6 built with
AddressSanitizer (`./Config` sanitizer question / `SANITIZER="asan"` in
`config.settings`). The reproducer script and full ASan output are attached
(`poc_rpc_timer_uaf.py`, `asan_stderr.log`).*

Steps (against the ASan build):

```
1. wss connect to /api with rpc-user credentials
2. {"method":"rpc.add_timer","params":{"timer_id":"B","every_msec":250,
       "request":{"method":"rpc.info","id":1}}}
3. {"method":"rpc.add_timer","params":{"timer_id":"A","every_msec":250,
       "request":{"method":"rpc.del_timer","params":{"timer_id":"B"},"id":2}}}
4. Within ~250 ms the rpc_do_timers event fires:
   A executes rpc.del_timer("B") -> free_rpc_timer(B)
   loop continues with e == freed B
```

ASan output (verbatim, trimmed):

```
==15029==ERROR: AddressSanitizer: heap-use-after-free on address 0x768935c60128
READ of size 8 at 0x768935c60128 thread T0
    #0 0x7629337cc2f3 in rpc_do_timers src/modules/rpc/rpc.c:2281
    #1 0x5c66f32d7ce5 in DoEvents src/api-event.c:200
    #2 0x5c66f310fe37 in SocketLoop src/ircd.c:952
    #3 0x5c66f31077df in main src/ircd.c:928

0x768935c60128 is located 8 bytes inside of 64-byte region [0x768935c60120,0x768935c60160)
freed by thread T0 here:
    #1 0x7629337cf396 in rpc_rpc_del_timer src/modules/rpc/rpc.c:2312
    #2 0x7629337cadc2 in rpc_call_json src/modules/rpc/rpc.c:1235
    #3 0x7629337cc250 in rpc_do_timers src/modules/rpc/rpc.c:2284

previously allocated by thread T0 here:
    #1 0x5c66f328ff60 in safe_alloc src/support.c:765
    #2 0x7629337cecd0 in rpc_rpc_add_timer src/modules/rpc/rpc.c:2263

SUMMARY: AddressSanitizer: heap-use-after-free src/modules/rpc/rpc.c:2281 in rpc_do_timers
```

Result (production build, glibc 2.39): the freed timer is executed once by
the event loop from freed heap ("zombie fire"), observable on the wire as an
unsolicited RPC error frame produced by `rpc_call_json()` parsing the freed
request tree. No crash occurs with stock glibc tcache (see §5).
Attached Files
ADVISORY.txt (9,574 bytes)
3rd party modules

Activities

syzop

2026-08-17 14:34

administrator   ~0023699

Thanks. I only noticed the report just now, after I got back from vacation an hour ago. I will take a look at fixing this.

syzop

2026-08-17 15:30

administrator   ~0023700

Alright, I could reproduce the issue. So the report is valid :)
I will fix it straight away, openly, not delay it to some sort of security release, given that:
1) this bug requires one to be an authenticated RPC user, typically this is the web panel, which already has lots of powers that could do massive disruptions on a network and possibly impact server availability -- similar to like IRCOps but a tad more powerful even
2) i could - just like you - only demonstrate a crash impact, RCE sounds unlikely given the nature of the bug (synchronous, not many ways to interfere, etc).
For the same reason I don't intend to request a CVE, since.. the issue doesn't really "buy" an attacker much, they could already mess up a server by e.g. flooding RPC calls or causing other chaos like glining everyone.

I want to compliment you on the report. I mean, sure it is AI based, but the reported issue, the impact assessment, the reproducer, everything was honest, helpful and clear.

syzop

2026-08-17 15:52

administrator   ~0023701

Last edited: 2026-08-17 15:53

Fixed in git https://github.com/unrealircd/unrealircd/commit/6c238ad1037675e96d9e7fa900d6faae04727e35

commit 6c238ad1037675e96d9e7fa900d6faae04727e35 (HEAD -> unreal60_dev, origin/unreal60_dev, origin/HEAD)
Author: Bram Matthys <[email protected]>
Date:   Mon Aug 17 15:33:42 2026 +0200

    Fix UAF in JSON-RPC rpc.del_timer method.

    We tried to delete a timer immediately, but by doing so we could be
    accessing an already freed other timer. Instead, we now "mark for
    deletion" and do the actual freeing later. Very similar to what we
    do in EventDel actually (for non-JSON-RPC events).

    I am going for an immediate public git fix rather than some kind of
    special security release because:
    1) only crash impact was demonstrated and RCE seems rather unlikely
    2) this requires an authenticated JSON-RPC account, which very likely
       already has lots of privileges that can mess up a network (eg add a
       gline on *@*) or can do resource type attacks (100% CPU). After all,
       JSON-RPC is similar to an IRCOp in terms of access, even more powerful.

    This issue was reported by A. Ramos <[email protected]> in
    https://bugs.unrealircd.org/view.php?id=6639


Again, I want to thank you for the report. Although I don't think it has much of a security impact, it was a real crash that is good to get fixed. For example, people could be unintentionally crashing the server, and that is now fixed in git. This will be in 6.2.7 release when it gets out at some point (no ETA).

I have made this bug report public now. It was good you reported it initially as 'private', that exactly how we want it to. But now that it has been analyzed and fixed, I set it to 'public'.

Issue History

Date Modified Username Field Change
2026-07-26 11:45 aramosf New Issue
2026-07-26 11:45 aramosf File Added: ADVISORY.txt
2026-08-17 14:34 syzop Note Added: 0023699
2026-08-17 15:30 syzop Note Added: 0023700
2026-08-17 15:50 syzop View Status private => public
2026-08-17 15:52 syzop Assigned To => syzop
2026-08-17 15:52 syzop Status new => resolved
2026-08-17 15:52 syzop Resolution open => fixed
2026-08-17 15:52 syzop Fixed in Version => 6.2.7
2026-08-17 15:52 syzop Note Added: 0023701
2026-08-17 15:53 syzop Note Edited: 0023701