https://bugs.kde.org/show_bug.cgi?id=524678

            Bug ID: 524678
           Summary: EXR: `isoSpeed` attribute from the file is passed to
                    `qRound()` unchecked, aborting on a Q_ASSERT
    Classification: Frameworks and Libraries
           Product: frameworks-kimageformats
      Version First unspecified
       Reported In:
          Platform: Compiled Sources
                OS: All
            Status: REPORTED
          Severity: crash
          Priority: NOR
         Component: general
          Assignee: [email protected]
          Reporter: [email protected]
                CC: [email protected], [email protected]
  Target Milestone: ---

Created attachment 195427
  --> https://bugs.kde.org/attachment.cgi?id=195427&action=edit
Crashing Inputs, potential Fix

**Version:** master `458257b7a32417c995d3a95a65b38586eee7a91a`
**Plugin:** `src/imageformats/exr.cpp`

Reading an EXR whose `isoSpeed` header attribute holds a value outside `int`
range
aborts the process on a Qt assertion. The file is otherwise completely valid
and decodes
normally — only the metadata value is hostile.

## Problem

`readMetadata()` rounds the attribute straight to an integer —
`exr.cpp:370-372`:

```c++
if (auto isoSpeed = header.findTypedAttribute<Imf::FloatAttribute>("isoSpeed"))
{
    image.setText(QStringLiteral(META_KEY_ISOSPEEDRATINGS),
                  QLocale::c().toString(qRound(isoSpeed->value())));   // line
371
}
```

`isoSpeed->value()` is a `float` read verbatim from the file. Qt's `qRound()`
goes
through `QtPrivate::qCheckedFPConversionToInteger`, which asserts the value is
representable (`qnumeric.h:508`, `:517`, `:523`):

```c++
Q_ASSERT(!std::isnan(value));
Q_ASSERT(value - FP(minimal) > FP(-1));
Q_ASSERT(value < maximalPlusOne);      // <-- fires
```

NaN, ±infinity, and any magnitude beyond `INT_MAX` all reach it, and an EXR
float
attribute may legally hold any of them.

Note this is *not* caught by the `catch (const std::exception &)` around
`EXRHandler::read()` — `Q_ASSERT` calls `qFatal()` → `abort()`, which is not an
exception. And because `readMetadata()` runs after the pixel loop
(`exr.cpp:493`), the
file must be fully valid and decodable to get there, which is why structurally
malformed inputs never surface this.

## Stack

>From an ASan build (oss-fuzz `kimgio_exr_fuzzer`):

```
#2  qAbort()                                              qassert.cpp:47
#6  qt_assert(char const*, char const*, int)              qassert.cpp:118
#7  qCheckedFPConversionToInteger<int, float, true, true> qnumeric.h:523
#8  qRound                                                qnumeric.h:577
#9  readMetadata(Imf_3_4::Header const&, QImage&)         exr.cpp:371
#10 EXRHandler::read(QImage*)                             exr.cpp:493
```

## Reproducing

Two files are attached:

- **`crash.exr`** (1447 bytes) — the original input from the fuzzer. Known to
reproduce.
- **`minimal.exr`** (358 bytes) — a 1×1 RGB half, uncompressed image whose
*only*
  unusual property is `isoSpeed`. Decodes fully, then aborts. Generated by the
attached
  `make_minimal.py`; `--iso nan` and `--iso inf` produce the other trigger
values.

Both set `isoSpeed` to the bytes `8f 4e cd 7a`, i.e. **5.33007e+35**.

```c++
QImage img;
img.load("minimal.exr");     // aborts in a build with asserts enabled
```

Reproduced directly against Qt 6.11.1 with the value taken from the file, no
fuzzer and
no sanitizer involved:

```
$ ./repro_qround
isoSpeed = 5.33007e+35   (INT_MAX = 2147483647)
ASSERT: "value < maximalPlusOne" in file
.../QtCore.framework/Headers/qnumeric.h, line 523
```

### Behaviour without asserts

`Q_ASSERT` is compiled out under `QT_NO_DEBUG`, so distribution builds do not
abort:

```
$ ./repro_qround_rel        # -O2 -DQT_NO_DEBUG -DNDEBUG
isoSpeed = 5.33007e+35
qRound   = 0
```

Worth noting that converting an unrepresentable float to `int` is undefined
behaviour in
C++, not merely lossy — `0` is what this compiler produced, not a guarantee. So
the
impact is an abort in assert-enabled builds (CI, debug, oss-fuzz) and a
meaningless ISO
value in the image metadata otherwise.

## Fix

kimageformats already has the helper for this, and already uses it in the same
function —
though its upper bound needs a one-character correction, see below.
`util_p.h:169`:

```c++
TI qRoundOrZero_T(SF d, bool *ok = nullptr)
{
    // checks for undefined behavior
    if (qIsNaN(d) || qIsInf(d) || d < SF() || d >
SF(std::numeric_limits<TI>::max())) {
        if (ok) { *ok = false; }
        return 0;
    }
    if (ok) { *ok = true; }
    return qRound(d);
}
```

`xDensity` is run through it via `dpi2ppm()` forty lines above the crash
(`exr.cpp:327-339`). The `isoSpeed` branch just does not follow that pattern.

```diff
--- a/src/imageformats/exr.cpp
+++ b/src/imageformats/exr.cpp
@@ -368,7 +368,15 @@ static void readMetadata(const Imf::Header &header, QImage
&image)

     // shot metadata
     if (auto isoSpeed =
header.findTypedAttribute<Imf::FloatAttribute>("isoSpeed")) {
-        image.setText(QStringLiteral(META_KEY_ISOSPEEDRATINGS),
QLocale::c().toString(qRound(isoSpeed->value())));
+        // The value comes straight from the file: it may be NaN, infinite or
outside
+        // the range of int, all of which make qRound() assert (and are
undefined
+        // behavior once asserts are compiled out). qRoundOrZero() performs
the same
+        // checks already used for xDensity above.
+        bool ok = false;
+        auto iso = qRoundOrZero(isoSpeed->value(), &ok);
+        if (ok) {
+            image.setText(QStringLiteral(META_KEY_ISOSPEEDRATINGS),
QLocale::c().toString(iso));
+        }
     }
```

Using the `ok` flag rather than the zero return means an unusable value skips
the tag
rather than recording `ISO 0`. No new includes required.

### The helper's upper bound is off by one

`qRoundOrZero_T` rejects `d > SF(std::numeric_limits<TI>::max())`. That bound
is not
representable in the source float type and **rounds up**: `float(INT32_MAX)` is
exactly
`2^31`, one more than `INT_MAX`. With `>`, that rounded-up value passes the
guard,
reaches `qRound()`, and trips `Q_ASSERT(value < maximalPlusOne)` — where
`maximalPlusOne`
is that same `2^31`.

So `isoSpeed = 2147483648.0f` (bytes `4F 00 00 00`) still aborts with the
helper applied.
Verified against Qt 6.11.1 and OpenEXR 3.4.14:

| isoSpeed | upstream | helper as-is (`>`) | helper with `>=` |
|---|---|---|---|
| 800 | tag `800` | tag `800` | tag `800` |
| 5.33e35 | abort 134 | tag skipped | tag skipped |
| NaN | abort 134 | tag skipped | tag skipped |
| **2^31** | **abort 134** | **abort 134** | **tag skipped** |

This also affects the branch that was already guarded: `xDensity` reaches the
same helper
through `dpi2ppm()` (`util_p.h:202`), and `xDensity = 54546084.0f` (bits
`0x4C5013A9`)
makes `dpi / 25.4f * 1000.0f` come out as exactly `2^31`, aborting at
`exr.cpp:332` —
before the `isoSpeed` branch is even reached. `qint64` has the same shape,
since
`double(INT64_MAX)` is exactly `2^63`.

Hence the second hunk:

```diff
     // checks for undefined behavior
-    if (qIsNaN(d) || qIsInf(d) || d < SF() || d >
SF(std::numeric_limits<TI>::max())) {
+    if (qIsNaN(d) || qIsInf(d) || d < SF() || d >=
SF(std::numeric_limits<TI>::max())) {
```

With that, the guard was checked against every class of float an EXR attribute
can hold:
5.33e35, NaN, ±inf, ±FLT_MAX, `float(INT_MAX)` and `2^31` are all rejected; 800
→ `800`,
12.5 → `13`, 0 → `0`.

## What was and was not verified

**Verified end to end**, with real OpenEXR 3.4.14 and Qt 6.11.1, no fuzzer and
no
sanitizer. `repro_plugin.cpp` mirrors `EXRHandler::read()` —
`Imf::RgbaInputFile`,
the scanline loop, then the `readMetadata()` conversion:

```
$ ./repro_plugin minimal.exr
decoded header : 1x1
decoded pixels : ok (1 scanline(s))
isoSpeed       : 5.33007e+35
ASSERT: "value < maximalPlusOne" in file .../qnumeric.h, line 523
exit=134

$ ./repro_plugin crash.exr
decoded header : 4x4
decoded pixels : ok (4 scanline(s))
isoSpeed       : 5.33007e+35
ASSERT: "value < maximalPlusOne" in file .../qnumeric.h, line 523
exit=134

$ ./repro_plugin minimal_nan.exr
decoded header : 1x1
decoded pixels : ok (1 scanline(s))
isoSpeed       : nan
ASSERT: "!std::isnan(value)" in file .../qnumeric.h, line 508
exit=134
```

Both files decode completely — header parsed, all scanlines read — before the
abort,
confirming this is reached only by a *valid* EXR and that `minimal.exr` is a
genuine
1x1 image rather than merely well-shaped bytes. The NaN variant trips the other
assert
(line 508), so both of Qt's preconditions are reachable from a file.

**The fix verified on the same files** (`repro_plugin_fixed.cpp`, using
`qRoundOrZero_T` from `util_p.h:169` with the corrected `>=` bound):

| file | isoSpeed | before | after |
|---|---|---|---|
| `minimal.exr` | 5.33e35 | abort, exit 134 | loaded, tag skipped |
| `crash.exr` | 5.33e35 | abort, exit 134 | loaded, tag skipped |
| `minimal_nan.exr` | NaN | abort, exit 134 | loaded, tag skipped |
| `minimal_ok.exr` | 800 | loaded, tag `800` | loaded, tag `800` |

Normal values are unaffected; only unusable ones lose the tag.

**Still not verified:** the `QImageReader` plumbing around the plugin — the
plugin
itself was not built, since that needs the KDE build stack. `repro_plugin.cpp`
reproduces `EXRHandler::read()`'s logic against the same libraries, and the
fuzzer stack
covers the `QImageReader` half.

## Possibly related

`src/imageformats/microexif.cpp:359` has the same shape on the write path
(`ds << T(qRound(v * den))`, where `rationalPrecision()` bottoms out at
`10^0`), so a
large enough value reaches `qRound()` out of range there too. Not tested for
reachability from a crafted input — mentioned only in case it is useful.

---
Best regards,
The Fandango Cispa Team

-- 
You are receiving this mail because:
You are watching all bug changes.

Reply via email to