Is RCE Really Just Low Severity?

- 8 mins

Is RCE Really Just Low Severity?

This is a friendly case study, not a callout. Big thanks to the {fmt} maintainers for the quick fix. I am happy with the way it was handled and I wish more teams moved this fast. Even if the final result was different from what I first expected, I think it was fair overall. This is the story of how I reported a command injection in the {fmt} library. I will share what I found, how it was fixed, and what I learned.

Advisory link: GHSA-65g5-63wg-xjh4

Maintainer thanks: vitaut

Everything was resolved in about eight days. That is very fast. I have reports in other projects that have sat in triage for months.

How I found it

When I read a new codebase I look for shell boundaries. I search for code that invokes system, popen, or exec style calls and passes in strings. If a user can influence those strings, that is a problem.

In {fmt} I noticed a small helper that tries to speak text on macOS. It formats a string and runs the system command for speech.

void say(const S& fmt, Args&&... args) {
  std::system(format("say \"{}\"", format(fmt, args...)).c_str());
}

At first glance it looks harmless. It builds a string and calls std::system. The risk appears when the formatted string contains untrusted data. Shells interpret special characters. Quotes and metacharacters can change meaning. If an attacker controls part of the string, they can escape the intended text and run a different command.

Another detail is surface area. This lived in a public header. That means downstream projects could see it and call it. If the library does not guarantee safe escaping, every adopter must remember to do it correctly. That is a lot to ask, and mistakes happen.

For me this was also a small wake-up call. {fmt} is a widely used, well-respected library. This was not a fancy side channel or a complex race. It was a one-line std::system wrapper. If something this simple can sit in a project this big, it is a reminder that you do not need exotic ideas to find real issues. Just reading code and asking “what happens if this is attacker-controlled?” still works.

What command injection means here

Preconditions and real exposure

For this to matter in practice, several things must be true.

If one of these is not true, the path is not reachable. That reduces exposure. It does not make the code safe, but it narrows who is at risk.

Why I first thought it was critical

When I first saw the helper, my head went straight to “critical”. It is a command runner. If untrusted input can reach it, the process can end up running attacker-chosen commands.

In CVSS terms that looks like the classic remote code execution shape: network reachable, low complexity, no privileges required, no user interaction, high impact on confidentiality and integrity. That maps to AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H, which scores 9.8. That is why my first instinct was critical.

One security issue, two severities

It is a command injection if fmt::say is ever called with attacker-controlled input. The code builds a shell command out of data and hands it to std::system.

What changes between my view and the advisory is not whether it is a security issue, but how broadly we assume that risky path is reachable in real deployments.

The maintainer later assessed the issue as Low for the library. That score is based on the library’s baseline exposure, not the worst-case application. The helper is macOS-specific, it lives in an optional header, and many builds will never include or call it.

I still see it as a classic RCE-shaped issue in any application that does wire fmt::say up to untrusted input. Both views can be true at the same time: the code path is high impact if reached, but its reach is limited in typical projects that use {fmt}.

In that world the attacker is often local to the host or to the app, the setup takes more work, and a user action may be involved. The CVSS score switches to AV:L/AC:H/PR:L/UI:R/S:U/C:L/I:L/A:N, which comes out around 3.3. That is Low. It is a fair call for the library as shipped.

Both statements can be true at the same time. The code path has high impact if reached. The exposure in typical builds is low. Putting both numbers next to each other shows how assumptions drive the score, and it explains why my initial critical view and the maintainer’s low advisory can both make sense.

A simple way to talk about severity

impact versus exposure.

Impact asks: if this code runs with attacker input, how bad is it?

Exposure asks: in real deployments with common settings, how likely is it that this risky code is reachable with attacker input?

Put together you get:

Both statements can live side by side without a conflict:

This split is useful because it lets us disagree on exposure without arguing about physics. We can agree that “this code path is command execution if reached” and then have a separate discussion about how many people are likely to reach it.

What changed

The maintainer acknowledged the problem. They removed the risky helper. The advisory lists the issue as Low severity based on exposure. Users on current versions no longer have this path. If you ship an older version and you used this helper, stop using it.

Personal opinion

I think there is a risk in the current scoring system if we minimise the severity of an issue to the lowest possible case. That makes the more severe issues stand out more clearly, but it is also a double edged sword. Issues that look lower impact at the library level, like this one, can be overlooked even though the actual severity in some applications is much higher. This is not a complaint about the way this was handled. It is a concern about the scoring of library issues in general. From the library side the issue might not look great, but from the application side it can be very severe.

Closing

This case shows that simple security issues can live in great projects. Two truths can be true at the same time. The code path has high impact if reached. The real-world exposure can be low.

I have also had more severe issues than this effectively dismissed elsewhere because they required some non default precondition. In contrast, the {fmt} team treated this as a real security issue, shipped a fix quickly, and published an advisory even while rating it Low for the library’s typical exposure. I am genuinely grateful for that.

Thanks again to @vitaut for the quick fix and the respectful discussion.

comments powered by Disqus