CVE-2026-6303
Overview
Background
- `SkiaImageDecoderBase`
- Blink’s base class wrapping Skia’s
SkCodecfor incremental, frame-by-frame image decoding (used by the Rust PNG decoder path). - `already_started_frame_`
- a cached optional recording which frame index has had
SkCodec::startIncrementalDecodecalled, so decoding can be resumed without re-initializing Skia’s decode state. - `SkCodec::startIncrementalDecode`
- the Skia call that initializes incremental decoding and stashes the destination pixel-buffer pointer (
frame.Bitmap().getPixels()) insideSkCodecfor subsequentincrementalDecodewrites. - `ImageFrame::ClearPixelData` / `AllocatePixelData`
- methods that free or (re)allocate the pixel buffer backing a decoded frame, e.g. when the cache is cleared or a frame is reset to
kFrameEmpty.
Root Cause Analysis
When decoding resumed for a frame whose index matched already_started_frame_, SkiaImageDecoderBase skipped calling startIncrementalDecode and let Skia continue writing into the pixel buffer whose pointer SkCodec had stashed earlier. However, that buffer can be freed or reallocated out from under the cached state: an intervening operation can clear the frame back to ImageFrame::kFrameEmpty and AllocatePixelData/TakeBitmapDataIfWritable/CopyBitmapData a new buffer, or a prior startIncrementalDecode can have failed, leaving already_started_frame_ set but the stashed pointer stale. Resuming then caused Skia to write through the dangling buffer pointer — a use-after-free write.
The fix invalidates already_started_frame_.reset() in two places: when the frame is found in the kFrameEmpty state (just before (re)allocation), and immediately before each startIncrementalDecode call, so any reallocation or a failed start forces a fresh startIncrementalDecode with the current buffer rather than reusing a stale pointer. The shipped regression tests confirm the sequence: partially decode a frame (setting already_started_frame_), clear/free that frame’s buffer via a cache-clearing out-of-bounds decode or ClearPixelData, then resume and verify decoding restarts correctly instead of writing to freed memory.
already_started_frame_ as implying Skia’s stashed destination pointer was still valid, when the backing buffer could be freed or reallocated in between; the fix resets already_started_frame_ whenever the buffer is (re)allocated or a decode is (re)started, forcing a fresh startIncrementalDecode.Attack Path
- Partially decode an animated PNG frame
A page supplies a truncated animated PNG so a frame decodes to
ImageFrame::kFramePartial, settingalready_started_frame_to that frame index afterstartIncrementalDecode. - Free/reallocate the frame buffer
Trigger an operation that frees that frame’s pixel data (e.g. a cache-clearing decode of an out-of-range frame index, which transitively calls
ImageFrame::ClearPixelData, or a frame reset tokFrameEmpty), whilealready_started_frame_still references it. - Resume decoding the same frame
Provide more data and decode the same frame index again; pre-patch, the code saw
already_started_frame_matching and skippedstartIncrementalDecode, so Skia resumed writing through its stale, now-freed buffer pointer. - Use-after-free write Skia’s incremental decode writes decoded pixels into the freed/reallocated buffer, the memory-corruption the regression tests are designed to catch.
Impact Assessment
ImageFrameGeneratorTest.ClearingPartiallyDecodedFrame test notes. The vendor advisory rates this high (“use after free in Codecs”) and does not report exploitation in the wild; the diff demonstrates the freed-buffer write but not a concrete control primitive, so escalation to code execution is (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fthird_party/blink/renderer/platform/graphics/image_frame_generator_test.cc |
modified | |
TESTthird_party/blink/renderer/platform/image-decoders/png/png_image_decoder_test.cc |
modified | |
ifthird_party/blink/renderer/platform/image-decoders/skia/skia_image_decoder_base.cc |
modified |
Files Changed
third_party/blink/renderer/platform/graphics/image_frame_generator_test.ccthird_party/blink/renderer/platform/image-decoders/png/png_image_decoder_test.ccthird_party/blink/renderer/platform/image-decoders/skia/skia_image_decoder_base.ccthird_party/blink/renderer/platform/image-decoders/skia/skia_image_decoder_base.h
Audit Directions
- Cached "already initialized" flags vs. buffer lifetimeAudit decoders/caches that skip re-initialization based on a remembered index/flag, and verify the cached state is invalidated whenever the underlying buffer is freed or reallocated.
- Stashed raw pointers in external librariesReview every
startIncrementalDecode/SkCodecinteraction (and similar APIs that retain a destination pointer) for paths where the destination buffer can be freed before the next write. - Clear/reallocate paths during partial decodeTrace
ImageFrame::ClearPixelData,AllocatePixelData, cache-eviction, and out-of-range frame requests that run while a frame iskFramePartial, confirming none leave a decoder able to resume into freed memory.
Patch
From a6357144e7bfc5c8ecc7220de4e95b7aa799b221 Mon Sep 17 00:00:00 2001 From: Lukasz Anforowicz <[email protected]> Date: Wed, 01 Apr 2026 15:44:32 -0700 Subject: [PATCH] [rust png] Invalidate `already_started_frame_` after clearing frames. This CL ensures that `already_started_frame_` doesn't become stale after: * Re-allocating the memory buffer backing a frame. * Failing a call to `startIncrementalDecode` The regression tests in this CL have been mostly created by Gemini CLI and then reviewed and cleaned up by the author. Fixed: 496282147 Change-Id: I92349cc2a0d7b9d1d401ab7256cb941a4d8383d1 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7703000 Reviewed-by: Daniel Cheng <[email protected]> Commit-Queue: Łukasz Anforowicz <[email protected]> Cr-Commit-Position: refs/heads/main@{#1608827} --- diff --git a/third_party/blink/renderer/platform/graphics/image_frame_generator_test.cc b/third_party/blink/renderer/platform/graphics/image_frame_generator_test.cc index 8a853696..0bda0dd 100644 --- a/third_party/blink/renderer/platform/graphics/image_frame_generator_test.cc +++ b/third_party/blink/renderer/platform/graphics/image_frame_generator_test.cc @@ -26,6 +26,7 @@ #include "third_party/blink/renderer/platform/graphics/image_frame_generator.h" #include <memory> + #include "base/features.h" #include "base/location.h" #include "base/test/metrics/histogram_tester.h" @@ -39,8 +40,10 @@ #include "third_party/blink/renderer/platform/scheduler/public/post_cross_thread_task.h" #include "third_party/blink/renderer/platform/testing/task_environment.h" #include "third_party/blink/renderer/platform/testing/testing_platform_support.h" +#include "third_party/blink/renderer/platform/testing/unit_test_helpers.h" #include "third_party/blink/renderer/platform/wtf/cross_thread_functional.h" #include "third_party/blink/renderer/platform/wtf/shared_buffer.h" +#include "third_party/blink/renderer/platform/wtf/text/string_builder.h" #include "third_party/blink/renderer/platform/wtf/vector.h" namespace blink { @@ -413,4 +416,65 @@ EXPECT_EQ(kNotFound, requested_clear_except_frame_); } +// This is a regression test for https://crbug.com/496282147. +// +// This is a more realistic, product-like, almost-end-to-end version of the +// `AnimatedPNGTests.ClearingPartiallyDecodedFrame` unit test. +TEST_F(ImageFrameGeneratorTest, ClearingPartiallyDecodedFrame) { + StringBuilder file_path; + file_path.Append(test::BlinkWebTestsDir()); + file_path.Append( + "/images/resources/png-animated-three-independent-frames.png"); + std::optional<Vector<char>> full_data_vec = + test::ReadFromFile(file_path.ToString()); + ASSERT_TRUE(full_data_vec); + base::span<const uint8_t> full_data = base::as_byte_span(*full_data_vec); + SkISize size(50, 50); + + // Can't reuse `generator_` from `SetUp`, because it sets `is_multi_frame` to + // `false`. Can't use `SetFrameCount`, because this test needs to use a real + // `SkiaImageDecoderBase` decoder, rather than `UseMockImageDecoderFactory`. + constexpr bool kIsMultiframe = true; + const Vector<SkISize> kSupportedSizes = {}; + generator_ = + ImageFrameGenerator::Create(size, kIsMultiframe, ColorBehavior::kTag, + cc::AuxImage::kDefault, kSupportedSizes); + + // Partially decode frame 1. + // + // `fcTL` chunk starts at offset 180. `fdAT` at 218. + // Let's provide 240 bytes - in the middle of `fdAT` chunk. + // + // After this step `SkiaImageDecoderBase::already_started_frame_` is `1`. + SkBitmap bitmap; + bitmap.allocN32Pixels(size.width(), size.height()); + cc::PaintImage::GeneratorClientId client_id = + cc::PaintImage::GetNextGeneratorClientId(); + auto partial_data = SharedBuffer::Create(full_data.first(240u)); + auto segment_reader = SegmentReader::CreateFromSharedBuffer(partial_data); + bool success = generator_->DecodeAndScale(segment_reader.get(), + /*all_data_received=*/false, 1, + bitmap.pixmap(), client_id); + EXPECT_TRUE(success); + + // Decode an out-of-bounds frame to clear the cache and transitively call + // `ImageFrame::ClearPixelData`. + success = generator_->DecodeAndScale(segment_reader.get(), + /*all_data_received=*/false, 1000, + bitmap.pixmap(), client_id); + EXPECT_FALSE(success); + + // Resume decoding of frame 1. Despite starting with + // `SkiaImageDecoderBase::already_started_frame_` set to `1` this operation + // needs to call `SkCodec::startIncrementalDecode` because the old buffer has + // been freed in the previous step. + auto full_shared_buffer = SharedBuffer::Create(full_data); + auto full_segment_reader = + SegmentReader::CreateFromSharedBuffer(full_shared_buffer); + success = generator_->DecodeAndScale(full_segment_reader.get(), + /*all_data_received=*/true, 1, + bitmap.pixmap(), client_id); + EXPECT_TRUE(success); +} + } // namespace blink diff --git a/third_party/blink/renderer/platform/image-decoders/png/png_image_decoder_test.cc b/third_party/blink/renderer/platform/image-decoders/png/png_image_decoder_test.cc index 5f647c0..5fbd931 100644 --- a/third_party/blink/renderer/platform/image-decoders/png/png_image_decoder_test.cc +++ b/third_party/blink/renderer/platform/image-decoders/png/png_image_decoder_test.cc @@ -871,6 +871,53 @@ EXPECT_EQ(frame1->GetStatus(), ImageFrame::kFrameComplete); } +// This is a regression test for https://crbug.com/496282147. +// +// This test uses `blink::ImageDecoder` and `blink::ImageFrame` APIs in a way +// that doesn't necessarily reflect how they would actually be used in the +// product (e.g. calling `ClearPixelData` and/or calling `Append` instead of +// `SetData`). This nevertheless seems like a valid test, because: +// +// * Supporting all usage patterns allowed by the public APIs (and the type +// system) seems more robust then 1) adding extra requirements on the caller +// of these APIs (such as never clearing a partially decoded frame), and/or 2) +// discovering the callers that may violate such requirements. +// * A separate `ImageFrameGeneratorTest.ClearingPartiallyDecodedFrame` test +// shows how a similr usage pattern is indeed reachable via web-exposed APIs. +TEST(AnimatedPNGTests, ClearingPartiallyDecodedFrame) { + Vector<char> full_data = ReadFile( + "/images/resources/" + "png-animated-idat-part-of-animation.png"); + ASSERT_FALSE(full_data.empty()); + auto decoder = CreatePNGDecoder(); + + // Provide only enough data for the first frame to be partial. + const size_t kPartialDataSize = 160; + scoped_refptr<SharedBuffer> data = + SharedBuffer::Create(base::span(full_data).first(kPartialDataSize)); + decoder->SetData(data.get(), false); + + // Partially decode frame 0. + ImageFrame* frame0 = decoder->DecodeFrameBufferAtIndex(0); + ASSERT_TRUE(frame0); + EXPECT_EQ(frame0->GetStatus(), ImageFrame::kFramePartial); + + // Manually clear frame 0 pixel data. + frame0->ClearPixelData(); + + // Provide more data by appending to the same `SharedBuffer`. + // This avoids clobbering the decoder state with a new `SetData` call. + data->Append(base::span(full_data).subspan(kPartialDataSize)); + + // Try to decode frame 0 again. This verifies that + // `SkCodec::startIncrementalDecode` has been called to reinitialize decoding + // state - avoiding writing to the memory buffer that has been freed by + // `ClearPixelData` above. + frame0 = decoder->DecodeFrameBufferAtIndex(0); + ASSERT_TRUE(frame0); + EXPECT_EQ(frame0->GetStatus(), ImageFrame::kFrameComplete); +} + // Verify that a malformatted PNG, where the IEND appears before any frame data // (IDAT), invalidates the decoder. TEST(AnimatedPNGTests, VerifyIENDBeforeIDATInvalidatesDecoder) { diff --git a/third_party/blink/renderer/platform/image-decoders/skia/skia_image_decoder_base.cc b/third_party/blink/renderer/platform/image-decoders/skia/skia_image_decoder_base.cc index a6766b5d..f4a8c84 100644 --- a/third_party/blink/renderer/platform/image-decoders/skia/skia_image_decoder_base.cc +++ b/third_party/blink/renderer/platform/image-decoders/skia/skia_image_decoder_base.cc @@ -301,6 +301,11 @@ UpdateAggressivePurging(current_frame_index); if (frame.GetStatus() == ImageFrame::kFrameEmpty) { + // `AllocatePixelData` (or `TakeBitmapDataIfWritable` / `CopyBitmapData`) + // calls mean that we can't reuse old buffer pointers that may have been + // stashed in `SkCodec` by previous `startIncrementalDecode` calls. + already_started_frame_.reset(); + wtf_size_t required_previous_frame_index = frame.RequiredPreviousFrameIndex(); if (required_previous_frame_index == kNotFound) { @@ -404,6 +409,7 @@ options.fPriorFrame = prior_frame_; options.fZeroInitialized = SkCodec::kNo_ZeroInitialized; + already_started_frame_.reset(); SkCodec::Result start_incremental_decode_result = codec_->startIncrementalDecode(image_info, frame.Bitmap().getPixels(), frame.Bitmap().rowBytes(), &options); diff --git a/third_party/blink/renderer/platform/image-decoders/skia/skia_image_decoder_base.h b/third_party/blink/renderer/platform/image-decoders/skia/skia_image_decoder_base.h index ab2c197..86726262 100644 --- a/third_party/blink/renderer/platform/image-decoders/skia/skia_image_decoder_base.h +++ b/third_party/blink/renderer/platform/image-decoders/skia/skia_image_decoder_base.h @@ -98,8 +98,9 @@ const wtf_size_t reading_offset_ = 0;
Regression Test / PoC
diff --git a/third_party/blink/renderer/platform/graphics/image_frame_generator_test.cc b/third_party/blink/renderer/platform/graphics/image_frame_generator_test.cc
index 8a853696..0bda0dd 100644
--- a/third_party/blink/renderer/platform/graphics/image_frame_generator_test.cc
+++ b/third_party/blink/renderer/platform/graphics/image_frame_generator_test.cc
@@ -26,6 +26,7 @@
#include "third_party/blink/renderer/platform/graphics/image_frame_generator.h"
#include <memory>
+
#include "base/features.h"
#include "base/location.h"
#include "base/test/metrics/histogram_tester.h"
@@ -39,8 +40,10 @@
#include "third_party/blink/renderer/platform/scheduler/public/post_cross_thread_task.h"
#include "third_party/blink/renderer/platform/testing/task_environment.h"
#include "third_party/blink/renderer/platform/testing/testing_platform_support.h"
+#include "third_party/blink/renderer/platform/testing/unit_test_helpers.h"
#include "third_party/blink/renderer/platform/wtf/cross_thread_functional.h"
#include "third_party/blink/renderer/platform/wtf/shared_buffer.h"
+#include "third_party/blink/renderer/platform/wtf/text/string_builder.h"
#include "third_party/blink/renderer/platform/wtf/vector.h"
namespace blink {
@@ -413,4 +416,65 @@
EXPECT_EQ(kNotFound, requested_clear_except_frame_);
}
+// This is a regression test for https://crbug.com/496282147.
+//
+// This is a more realistic, product-like, almost-end-to-end version of the
+// `AnimatedPNGTests.ClearingPartiallyDecodedFrame` unit test.
+TEST_F(ImageFrameGeneratorTest, ClearingPartiallyDecodedFrame) {
+ StringBuilder file_path;
+ file_path.Append(test::BlinkWebTestsDir());
+ file_path.Append(
+ "/images/resources/png-animated-three-independent-frames.png");
+ std::optional<Vector<char>> full_data_vec =
+ test::ReadFromFile(file_path.ToString());
+ ASSERT_TRUE(full_data_vec);
+ base::span<const uint8_t> full_data = base::as_byte_span(*full_data_vec);
+ SkISize size(50, 50);
+
+ // Can't reuse `generator_` from `SetUp`, because it sets `is_multi_frame` to
+ // `false`. Can't use `SetFrameCount`, because this test needs to use a real
+ // `SkiaImageDecoderBase` decoder, rather than `UseMockImageDecoderFactory`.
+ constexpr bool kIsMultiframe = true;
+ const Vector<SkISize> kSupportedSizes = {};
+ generator_ =
+ ImageFrameGenerator::Create(size, kIsMultiframe, ColorBehavior::kTag,
+ cc::AuxImage::kDefault, kSupportedSizes);
+
+ // Partially decode frame 1.
+ //
+ // `fcTL` chunk starts at offset 180. `fdAT` at 218.
+ // Let's provide 240 bytes - in the middle of `fdAT` chunk.
+ //
+ // After this step `SkiaImageDecoderBase::already_started_frame_` is `1`.
+ SkBitmap bitmap;
+ bitmap.allocN32Pixels(size.width(), size.height());
+ cc::PaintImage::GeneratorClientId client_id =
+ cc::PaintImage::GetNextGeneratorClientId();
+ auto partial_data = SharedBuffer::Create(full_data.first(240u));
+ auto segment_reader = SegmentReader::CreateFromSharedBuffer(partial_data);
+ bool success = generator_->DecodeAndScale(segment_reader.get(),
+ /*all_data_received=*/false, 1,
+ bitmap.pixmap(), client_id);
+ EXPECT_TRUE(success);
+
+ // Decode an out-of-bounds frame to clear the cache and transitively call
+ // `ImageFrame::ClearPixelData`.
+ success = generator_->DecodeAndScale(segment_reader.get(),
+ /*all_data_received=*/false, 1000,
+ bitmap.pixmap(), client_id);
+ EXPECT_FALSE(success);
+
+ // Resume decoding of frame 1. Despite starting with
+ // `SkiaImageDecoderBase::already_started_frame_` set to `1` this operation
+ // needs to call `SkCodec::startIncrementalDecode` because the old buffer has
+ // been freed in the previous step.
+ auto full_shared_buffer = SharedBuffer::Create(full_data);
+ auto full_segment_reader =
+ SegmentReader::CreateFromSharedBuffer(full_shared_buffer);
+ success = generator_->DecodeAndScale(full_segment_reader.get(),
+ /*all_data_received=*/true, 1,
+ bitmap.pixmap(), client_id);
+ EXPECT_TRUE(success);
+}
+
} // namespace blink
diff --git a/third_party/blink/renderer/platform/image-decoders/png/png_image_decoder_test.cc b/third_party/blink/renderer/platform/image-decoders/png/png_image_decoder_test.cc
index 5f647c0..5fbd931 100644
--- a/third_party/blink/renderer/platform/image-decoders/png/png_image_decoder_test.cc
+++ b/third_party/blink/renderer/platform/image-decoders/png/png_image_decoder_test.cc
@@ -871,6 +871,53 @@
EXPECT_EQ(frame1->GetStatus(), ImageFrame::kFrameComplete);
}
+// This is a regression test for https://crbug.com/496282147.
+//
+// This test uses `blink::ImageDecoder` and `blink::ImageFrame` APIs in a way
+// that doesn't necessarily reflect how they would actually be used in the
+// product (e.g. calling `ClearPixelData` and/or calling `Append` instead of
+// `SetData`). This nevertheless seems like a valid test, because:
+//
+// * Supporting all usage patterns allowed by the public APIs (and the type
+// system) seems more robust then 1) adding extra requirements on the caller
+// of these APIs (such as never clearing a partially decoded frame), and/or 2)
+// discovering the callers that may violate such requirements.
+// * A separate `ImageFrameGeneratorTest.ClearingPartiallyDecodedFrame` test
+// shows how a similr usage pattern is indeed reachable via web-exposed APIs.
+TEST(AnimatedPNGTests, ClearingPartiallyDecodedFrame) {
+ Vector<char> full_data = ReadFile(
+ "/images/resources/"
+ "png-animated-idat-part-of-animation.png");
+ ASSERT_FALSE(full_data.empty());
+ auto decoder = CreatePNGDecoder();
+
+ // Provide only enough data for the first frame to be partial.
+ const size_t kPartialDataSize = 160;
+ scoped_refptr<SharedBuffer> data =
+ SharedBuffer::Create(base::span(full_data).first(kPartialDataSize));
+ decoder->SetData(data.get(), false);
+
+ // Partially decode frame 0.
+ ImageFrame* frame0 = decoder->DecodeFrameBufferAtIndex(0);
+ ASSERT_TRUE(frame0);
+ EXPECT_EQ(frame0->GetStatus(), ImageFrame::kFramePartial);
+
+ // Manually clear frame 0 pixel data.
+ frame0->ClearPixelData();
+
+ // Provide more data by appending to the same `SharedBuffer`.
+ // This avoids clobbering the decoder state with a new `SetData` call.
+ data->Append(base::span(full_data).subspan(kPartialDataSize));
+
+ // Try to decode frame 0 again. This verifies that
+ // `SkCodec::startIncrementalDecode` has been called to reinitialize decoding
+ // state - avoiding writing to the memory buffer that has been freed by
+ // `ClearPixelData` above.
+ frame0 = decoder->DecodeFrameBufferAtIndex(0);
+ ASSERT_TRUE(frame0);
+ EXPECT_EQ(frame0->GetStatus(), ImageFrame::kFrameComplete);
+}
+
// Verify that a malformatted PNG, where the IEND appears before any frame data
// (IDAT), invalidates the decoder.
TEST(AnimatedPNGTests, VerifyIENDBeforeIDATInvalidatesDecoder) {
Original Bug Report
Potential Heap UAF Write in SkiaImageDecoderBase via Stale already_started_frame_
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: A potential heap use-after-free (UAF) vulnerability exists in SkiaImageDecoderBase when decoding images via the WebCodecs API. A cached frame index is not reset during aggressive caching purges, allowing an attacker to reuse a freed pixel buffer pointer. This leads to an attacker-controlled out-of-bounds/UAF write in the renderer process.
Affected files:
third_party/blink/renderer/platform/image-decoders/skia/skia_image_decoder_base.ccthird_party/skia/src/codec/SkWuffsCodec.cppthird_party/blink/renderer/modules/webcodecs/image_decoder_core.cc
Estimated timestamp from git blame: 2024-09-04
Verdict: A highly exploitable Heap Use-After-Free (UAF) write exists in SkiaImageDecoderBase.
Context Summary: Initial logic and frame decoding parameters are validated. When an ImageDecoder handles incremental GIF decoding, SkWuffsCodec caches a raw pointer (fIncrDecDst) to the active pixel buffer.
Vulnerability: Standard processing of ClearCacheExceptFrame frees cached pixel buffers to reclaim memory. However, the system fails to reset the already_started_frame_ tracker. We jump directly to the exploit primitive: when decoding resumes, the stale tracker bypasses the startIncrementalDecode() initialization block. SkWuffsCodec::incrementalDecode() executes immediately, writing attacker-controlled pixel data directly into the freed heap allocation via the dangling fIncrDecDst pointer. MiraclePtr provides no protection as this is a raw uint8_t* within third-party code.
Potential Steps to Trigger:
- Feed
ImageDecodera multi-frame GIF with partial data for Frame 1. - Decode Frame 1 to initialize the Skia codec pointer and set
already_started_frame_ = 1. - Abort the decode (
decoder.reset()) and decode Frame 0. This triggers cache clearing, freeing Frame 1’s buffer. - Perform heap grooming to place a target object in the freed slot.
- Provide the remaining GIF data and decode Frame 1. The decoder bypasses pointer re-initialization and writes GIF payload bytes into the target object.
Note: These are suggested potential steps, as Fortify LLM agent doesn’t yet have the ability to run code.
Suggested Fix:
Invalidate already_started_frame_ and notify the underlying codec whenever a partially decoded frame’s pixel data is cleared during cache eviction (e.g., inside ClearCacheExceptFrame or ClearPixelData).
Evaluated with Chrome root at commit: a3f5fcb392f2902650ca2b71820e7e418787e18b
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. Please feel free to reach out to me if you have concerns or feedback.