Please report privately through GitHub's Report a vulnerability form rather than opening a public issue. That opens a private thread with the maintainer and, if it is a real issue, becomes the advisory that notifies people who depend on the packages.
Useful things to include: the version, the command or API call, and a crafted shard image or payload that reproduces it. There is no bounty.
Decoding consumes untrusted input. That is the point of the tool: images arrive from another machine, over a channel nobody controls, and may have been produced by someone else entirely. Every field in a shard header, every byte of a payload, and every pixel of a captured image is therefore attacker-controlled data, and the decoder is written on that basis.
What the project treats as a vulnerability:
- a crafted shard image causing a write outside the output path the user chose
- a crafted shard image causing code execution, or unbounded memory/CPU consumption
- a decode reporting success while producing bytes that are not the ones encoded
- an encrypted payload being recoverable without the password, or a tampered one decrypting without an error
What it does not: the tool writes with the permissions of whoever runs it, so an explicit
single-file -o can replace a file that user could already replace. Point -o somewhere
sensible. Archive output is stricter: its destination must be absent or empty and is never merged
into an existing tree.
QrShard.Recorder captures every compositor frame on the nominated CaptureScreen, including
other windows. Treat the output folder as a full-desktop recording. QrShard.Display only presents
local stills 1:1; it is not a decoder and does not read shard payloads. Neither host is in the
Native-AOT QrShard binary or the nuget.org QrShard.Tool package. From v1.7.6 they also
attach as self-contained win-x64 GitHub Release zips; they remain Windows Desktop SDK
builds from QrShard.Windows.slnx. ffmpeg receive --screen remains the documented path for
decoding a remote window without dumping the whole desktop. Keep Display TerminationColor in
sync with Recorder so a single-pass run can stop the capture without extra keystrokes.
A successful decode means the reconstructed file matched a SHA-256 carried inside the shards, so bit-exactness is verified rather than assumed. Beneath that sit a CRC-32 per payload and CRC-32-protected headers.
Header integrity is a checksum, not a MAC: CRC-32 detects accidental corruption, and an attacker who rewrites a header can recompute it. Current encrypted shards use the AuthMetaV2 AES-GCM associated-data suite: it domain-separates the cipher/KDF parameters and binds the original size, SHA-256, exact UTF-8 file name, and family-wide transformation flags. In particular, Archive cannot be toggled to turn an authenticated ordinary payload into an extraction operation. Per-image ordinal/payload fields and recovery geometry remain CRC/family/final-SHA checked rather than MACed. Legacy encrypted shards retain their documented older AAD behavior. Treat every shard set as attacker-modifiable in transit at the unbound-header level; for an unencrypted set, no header field has cryptographic authentication.
Encryption is AES-256-GCM with a PBKDF2-SHA256 key at 600 000 iterations, a per-file random salt and nonce.
-p/--password can be retained in shell history or visible through process inspection. Prefer
--password-file (strict UTF-8, optional UTF-8 BOM, 64 KiB/4096-character cap) with suitable local
permissions, or --password-stdin (one bounded line). The three sources are mutually exclusive;
there is deliberately no environment-variable source. Wrong-password or authentication failure does not
publish plaintext, but encryption does not hide the shard geometry, file name, original length,
image count, or other cleartext header metadata.
The password is converted to UTF-8 exactly as supplied before PBKDF2; QrShard does not apply Unicode normalization. Visually identical composed and decomposed strings are therefore different passwords.
Session files contain the captured shard payloads themselves. For an unencrypted transfer that is
plaintext, not merely metadata. A v2 session is atomically initialized with private permissions,
then updated under an exclusive persistent .lock sidecar as an append-only CRC-framed journal.
The final incomplete record can be recovered; interior corruption fails closed. Same-family valid
candidates that disagree for one ordinal become a compact durable erasure, so neither arrival order
selects a winner; inconsistent family metadata is rejected instead of persisted. Cleanup quarantines
and authenticates the exact opened object before deletion. Protect the
session and its reserved .lock sidecar like the source; session-backed explicit output must be a
fresh path.
The current decoder does not stream unverified bytes into the final pathname. A single-file restore is written to an unpredictable same-directory staging file, checked for exact length and SHA-256, then atomically moved into place. Verification, decompression, and pre-publication I/O failures leave the previous destination intact, and the staging file is removed best-effort. Once the move commits, the old filesystem object is gone; a later Windows attribute-restoration failure can therefore report an error with the verified replacement already present.
"Atomic" here describes what concurrent filesystem users see; it is not a power-loss durability guarantee. QrShard does not fsync every restored file and parent directory. Keep the source shards or another backup until the receiving storage has been persisted and independently checked when crash recovery matters.
Atomic replacement creates a new filesystem object. If explicit -o replaces an existing file,
QrShard copies its Unix rwx mode bits, or its Windows DACL and basic file attributes. It does not
preserve ownership, timestamps, ACL details outside that Windows DACL, extended attributes,
alternate data streams, sparse-file state, or hard-link identity. Choose a fresh destination path
when preserving those properties matters. A new single-file output retains the staging object's
private security (requested Unix 0600, or stricter after umask, or a protected owner-only Windows
DACL); a new archive root likewise requests Unix 0700 or uses an owner-only DACL.
The output parent is itself a trust boundary. Use a directory that untrusted local users cannot rename or delete entries in; private staging permissions do not make path-based publication safe against a principal that already has those rights on the parent directory.
For an archive, the decrypted/decompressed tar is first length- and SHA-256-verified in a private temporary directory. Entries are then validated and extracted into a private sibling directory; the complete tree is published only after every entry succeeds. A destination that is a file or a non-empty directory is refused. Entry paths must be safe relative paths and must not collide by case or Unicode normalization. Only regular files and directories are accepted: symbolic links, hard-link entries, devices, and other special tar types are rejected. The bundled encoder skips reparse-point links inside selected folders (and rejects one selected as a top-level input), while hard-linked paths are copied as independent regular files.
If the explicit archive destination is an existing empty directory, the published root carries forward its full Unix directory mode, including setgid/sticky policy bits, or its Windows DACL and basic attributes. Ownership, timestamps, extended attributes, and other metadata are not carried. This destination-root policy is separate from archive entry metadata. Archive publication is complete and non-merging; unlike the single-file move, replacing that empty directory is not promised as one atomic filesystem operation.
Archive transfer is not a backup format. It carries Unix regular-file owner/group/other rwx bits, including executability; extraction applies them subject to the receiver's umask, while .NET strips setuid, setgid, and sticky special bits. Ownership, ACLs, extended attributes, alternate data streams, sparse-file state, directory modes/metadata, and hard-link identity are outside the portable contract.
Single-file and prepared-archive payloads are capped at 1.5 GB. Ordinary image loads have a
500-million-pixel ceiling and a separate pre-load admission charge of roughly 6 bytes/pixel against
DecodeMemoryBudgetMB. Batch worker planning uses the largest identifiable input at roughly
40 bytes/pixel; with the 4000 MB default this is about 332 MB/worker and 12 workers for 4K, or
1.92 GB/worker and 2 workers for 48 MP. The same setting also bounds actual path/input
materialization, retained CRC-valid payloads/count, fusion salvage/work, lifetime watch/video
state, retained CLI journal state, and the public incremental session's default retained state.
Embeddings can give QrShardDecodeSession a smaller explicit decimal-MB ceiling; rejected additions
do not mutate it. These are independent conservative admission ceilings, not one subtractive pool
or a hard process-working-set limit. Photo fusion admits at most eight compatible captures per layout,
1024 layouts and 512 MiB of work, with retained cells limited to one eighth of the decode budget.
Sessions admit at most one million unique keys/two million frames, and the physical journal is
capped at three times the decoded-byte budget. APNG slideshow creation and native animated-image recording decode are capped at 256 MiB
of decoded RGB frames; animated recording input is additionally capped at 4096 frames. Archive
encode/decode is capped at 100,000 entries and 128 path segments per entry; decode also caps its
collision-checking trie at 200,000 distinct path nodes.
External helpers are never shell-invoked. A configured helper must be absolute; otherwise QrShard
uses an absolute PATH lookup that physically canonicalizes every directory and executable, skips
relative, current, and application directories plus their symlink/junction aliases, and fails
closed if an entry cannot be resolved (Unix helpers must also be executable). ffmpeg receives
structured arguments, a restricted child PATH, -nostdin, one worker/filter thread,
protocol/pixel limits, bounded stderr and termination
waits. xrandr and browser launchers use the same trusted-resolution policy and bounded execution
where output is consumed. Terminal diagnostics replace control, bidi-format, line/paragraph
separator, and invalid-surrogate characters and cap hostile names/messages; JSON uses JSON escaping.
CodeQL scans production C# and GitHub Actions on pull requests, main, a weekly schedule, and manual
dispatch. C# analysis keeps CodeQL's default remote model and adds the file threat submodel because
bytes read from shard, image, archive, and session files are untrusted. It deliberately does not
enable the entire preview local group: that group also treats command-line arguments, environment
variables, and Path.GetTempPath() as attacker-controlled, although they are explicit same-user
configuration here. On this file-processing CLI that broader model reports every intended input,
output, temporary path, and absolute helper launch as path or command injection. Those boundaries
are instead enforced by containment/collision checks, private staging and reparse-point handling,
absolute helper resolution, structured argument lists, and their adversarial regression tests.
Test-source files are excluded from the production CodeQL database; they still run under the full
cross-platform test matrix. Revisit this model before adding a service, remotely supplied path, or
different process-launch boundary.
Beginning with v1.7.0, tagged releases publish SHA256SUMS only after the exact Native-AOT binaries
and NuGet packages have been tested with .NET SDK 10.0.400 on versioned GitHub-hosted runner labels.
An active no-bypass tag ruleset blocks update/deletion of v*, and the workflow independently
peels the remote tag and compares it with the event commit before attestation, draft creation, and
publication. GitHub
stores signed SLSA build-provenance for every release file, including SHA256SUMS, and signed
SPDX 2.2 SBOM attestations generated separately for each binary archive and package. Native-AOT
archives use their RID-specific restore graph, including the corresponding runtime/compiler packs;
tool/core packages use their ordinary framework-dependent graphs. Verify them using the constrained
commands in README. Releases produced by this workflow are immutable, so GitHub locks their assets
and tag and creates an additional release attestation. Older releases predate these controls.
Write-enabled promotion jobs never check out or build repository code. Package publication first validates the exact Tool/Core candidates, bounded ZIP structure, and every existing version across NuGet.org and GitHub Packages. NuGet.org copies must be repository-signed and semantically match apart from that signature; GitHub copies must match byte-for-byte. Only after all present copies pass are missing immutable versions pushed and downloaded again, making an ambiguous partial publication safely rerunnable. Draft assets use the same fail-closed exact-inventory policy.
These attestations authenticate artifact digests and workflow provenance, not platform publisher
identity. The Windows executable is not Authenticode-signed. After symbol stripping, the workflow
applies and strictly verifies an ad-hoc macOS signature; this is not a Developer ID signature,
notarization, or assertion of publisher identity. SHA256SUMS is plaintext and must not be trusted
without its attestation.
The release executable is QrShard.exe on Windows and case-sensitive QrShard on Unix.
The linux-x64 and linux-arm64 Native-AOT assets require glibc 2.35 and 2.39 respectively.
The macOS candidate also requires successful symbol extraction and stripping plus a non-empty dSYM
whose Mach-O UUID matches the shipped binary. Those operations run directly because the Native AOT Apple runtime pack restored by
SDK 10.0.400 (currently 10.0.11) contains stale temporary module-cache debug references that its
MSBuild wrapper misclassifies; an actual dsymutil, strip, signing, or signature-verification
failure still stops the release.
These did not produce wrongly verified bytes, but they could terminate a batch on one malformed image, suppress restoration of complete siblings, size diagnostic buffers from hostile metadata, or make terminal output misleading. The table separates their affected ranges and fix versions.
| Defect | Affects | Fixed in |
|---|---|---|
| Terminal escape sequences in a header file name reaching the console unescaped | v1.0.0 – v1.4.0 | 1.5.0 |
Image.Identify in the worker-size probe raising an unlisted exception type |
v1.5.2 – v1.5.2 | 1.6.0 |
| The same, in the two single-image load paths | v1.0.0 – v1.6.0 | 1.6.1 |
The same, in VideoDecoder.IsAnimatedImage — reached by decode before any decoder runs |
v1.0.0 – v1.6.0 | 1.6.1 |
The same, in RecordingFrameSource's two frame loaders (no filter at all) |
v1.0.0 – v1.6.0 | 1.6.1 |
| An incomplete file discarding every complete file grouped after it | v1.0.0 – v1.6.0 | 1.6.1 |
| Diagnostics and heatmap buffers sized from an unvalidated metadata strip | v1.3.4 – v1.6.0 | 1.6.1 |
The four Image.Identify rows share one root cause across six call sites: exception allowlists
were used where the actual policy was that one malformed image must not abort a batch. The 1.6.0
fix broadened one filter but left neighbouring call sites enumerating exception types. The
terminal-output, group-ordering, and diagnostic-buffer rows are separate defects. The durable
image-load mitigation is the blanket per-image catch in CollectShards: a policy rather than a
growing list of decoder exceptions.
Three separate defects, all reachable from an ordinary qrshard decode of images the user chose
to accept, and all landing on the outcome this project treats as the serious one — a decode
reporting success while producing bytes that are not the ones encoded, or none at all.
| Defect | Affects | Fixed in |
|---|---|---|
Header file name naming a Win32 device (NUL, CON, COM1…) |
v1.0.0 – v1.4.0 | 1.5.0 |
| Reed-Solomon erasure decode spending its whole verification margin | v1.0.0 – v1.4.0 | 1.5.0 |
| Metadata strip declaring zero complete codewords with ECC on | v1.0.0 – v1.4.0 | 1.5.0 |
The device name is the one to understand. On Windows, opening <dir>\NUL succeeds, discards
every byte, and creates no file — and File.Exists on it is false, so the collision check never
diverted. The SHA-256 could not catch it either, because it is computed over the payload as it is
written rather than read back: written still matched, the digest still matched, and the decode
printed SHA-256 verified over a file that does not exist. Total data loss presented as success.
Reachable only without -o, since an explicit output path is used exactly as given.
The Reed-Solomon defect is a silent miscorrection. Two syndromes are meant to stay unspent so the final check can detect a wrong answer, but the reserve was enforced against the erasure count only — one extra error at the maximum erasure count consumed it, leaving the verification vacuous. Erasure flags come from the colour classifier's confidence, so "more errors than were flagged" is an ordinary condition rather than an exotic one.
The third let the FEC pass write nothing and report success, so the per-worker recovered buffer was handed on still holding the previous image's stream — valid header CRC, valid payload CRC — and a shard was accepted from an image that contributed none of its bytes.
Alongside these, 1.5.0 bounds a set of denial-of-service paths where a small crafted image could size gigabytes of buffers or stall a decode, and stops one malformed image aborting a whole folder decode and discarding every other image's successful result. Those are availability rather than integrity, and are not itemised here.
In the interactive CLI, reaching these historical defects required the operator to decode an
attacker-controlled shard image. An embedding service that automatically accepts uploaded images
should instead treat that input as remotely supplied. Take the latest stable release if you
decode images from anywhere you do not control, and pass -o explicitly regardless.
A crafted image could steer the decode write
outside the output directory, or to an absolute path, truncating the target before verification
ran. Reachable from qrshard decode <folder> with no -o, and from DecodeImages with no
outputPath.
It was fixed in two parts, because the first fix missed one of them.
| Payload | Affects | Fixed in |
|---|---|---|
| Single file | v1.0.0 – v1.3.8 (packages 1.3.5 – 1.3.8) | 1.3.9 |
| Archive — a folder, carried as a tar | v1.0.0 – v1.3.9 (packages 1.3.5 – 1.3.9) | 1.3.10 |
1.3.9 sanitized the single-file destination only. The archive branch still derived its directory
from the header via Path.GetFileNameWithoutExtension, which is not a sanitizer — a name of
... becomes .., the parent directory — and the tar extractor's own containment check was then
anchored to that already-escaped root.
1.3.10 is the first release in which both are fixed, and it is the floor rather than the recommendation — fixes go to the latest release only, so take the latest stable release:
dotnet tool update -g QrShard.Tool
dotnet add package QrShard.Core
or the latest binaries. If you cannot upgrade, always pass an explicit output path — -o on the
command line, or outputPath to DecodeImages — which is used exactly as given and is not
influenced by the header.
Packages 1.3.5 – 1.3.9 are unlisted and deprecated on nuget.org. They still resolve for anyone who references them explicitly, so existing builds do not break.
Fixes go to the latest release only. There are no maintained release branches.