Skip to content

Security review of xclbinutil - #9968

Merged
stsoe merged 7 commits into
Xilinx:masterfrom
stsoe:review
Aug 6, 2026
Merged

Security review of xclbinutil#9968
stsoe merged 7 commits into
Xilinx:masterfrom
stsoe:review

Conversation

@stsoe

@stsoe stsoe commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Problem solved by the commit

A thorough manual security review of src/runtime_src/tools/xclbinutil identified 17 vulnerabilities across 10 files where wire-format fields from attacker-controlled xclbin files were used without adequate bounds checking. The fixes are grouped into six categories below.

Bug / issue (if any) fixed, which PR introduced the bug, how it was discovered

All bugs are pre-existing. Discovered by manual security review following CodeQL alert triage (alerts #129#156). None of the bugs below were previously flagged by CodeQL.

How problem was solved, alternative solutions (if any) and why they were rejected

Category 1 — CWE-125: Unterminated fixed-size string fields (5 bugs)

Five parsers passed fixed-size embedded char/uint8_t arrays from wire-format structs directly to boost::format("%s"), which calls strlen and reads to the first null byte. A crafted xclbin with no null terminator in the last entry's string field causes a heap OOB read past the section buffer.

Affected: ip_data::m_name (SectionIPLayout), clock_freq::m_name (SectionClockFrequencyTopology), mem_data::m_tag (SectionMemTopology, SectionGroupTopology), debug_ip_data::m_name (SectionDebugIPLayout).

Fix: Added XclBinUtilities::bounded_fixed_cstr<N>() template that uses strnlen to bound the read to the field width. Applied at all put() and TRACE call sites in all five parsers.


Category 2 — CWE-125: Unchecked wire-format array offsets in SectionAIEPartition (7 bugs)

writeAIEPartitionImage() had bufferSize available but never passed it to any helper function, leaving all wire-format offset/size pointer arithmetic unguarded. Seven sites were affected including one where attacker-controlled data was written to a PDI output file on disk (information disclosure).

Affected fields: aie_pdi.offset/size, start_columns.offset/size, cdo_groups.offset/size, cdo_group::mpo_name, dpu_kernel_ids.offset/size, pre_cdo_groups.offset/size, pdi_image.offset/size.

Fix: Added bufferSize parameter to all helper functions. Added overflow-safe bounds checks (count > (bufferSize - offset) / sizeof(T)) before every array pointer formation. Replaced raw mpo_name pointer arithmetic with bounded_mpo_cstr(). Bounds-check temporaries reused in array cast and loop bounds.


Category 3 — CWE-190/191: Integer overflow and underflow (4 bugs)

  • SectionMCS.cxx: m_chunk::m_offset (uint64_t) was added to pointer before the bounds check. Pointer wraparound on x86-64 caused the check to pass for values near UINT64_MAX. Fix: validate offset and size directly in offset-space before forming the pointer.

  • SectionDNACertificate.cxx: paddingSize computed as (sectionSize - signatureSizeBytes) - paddingOffset underflows to ~2^64 when paddingOffset is large, because the guard did not account for the signature and length-field overhead. Fix: include overhead in the guard; add explicit underflow check before subtraction.

  • XclBinUtilities.cxx (seekg): signatureOffset + signedByOffset computed as unsigned int before widening to std::streamoff, wrapping the seek position to zero. Fix: cast to uint64_t before adding. Also added missing gcount() checks on all three signature reads.

  • XclBinUtilities.cxx (MBG loop): inner while accessed memIndexVector[idx + 1] with no idx + 1 < size guard, causing a one-past-the-end read on the last iteration. Fix: add bounds guard to the while condition.


Category 4 — CWE-190: Dead guard + silent integer truncation in Section.cxx (1 bug)

The guard m_sectionSize > UINT64_MAX is tautologically false for a uint64_t. The subsequent cast to unsigned int silently truncated any section size above 4 GiB, allocating a tiny buffer while all downstream consumers saw the wrong size with no error. Same dead-code pattern in two locations. Fix: replace > UINT64_MAX with > UINT32_MAX.


Category 5 — Logic/robustness bugs (2 bugs)

  • XclBinClass.cxx: ptInterfaces[0] was accessed unconditionally without checking whether the vector was empty, causing undefined behavior (crash) for xclbins with no interfaces node. Fix: add if (ptInterfaces.empty()) return; guard.

  • XclBinUtilities.cxx: istream::read() of the SignatureHeader struct had no gcount() check. A truncated file left the struct zero-initialized, silently skipping all data reads. Fix: add gcount() check consistent with the existing checks on the data reads.


Category 6 — CWE-78: Command injection in legacy popen path (1 bug)

The Boost < 1.64 fallback in exec() joined cmd and args with spaces and passed the result to popen(). A user-controlled argument (e.g. --add-pskernel path) containing shell metacharacters could inject arbitrary shell commands. The Boost >= 1.64 path using boost::process::child is not affected. Fix: added shell_quote() helper that wraps each argument in single quotes and escapes embedded single quotes as '"'"', preventing shell interpretation of any argument content.

Risks (if any) associated the changes in the commit

Low across all categories. All changes are input validation only; behavior for well-formed xclbin files is unchanged. Malformed files that previously caused silent heap reads, data corruption, or crashes now throw std::runtime_error, which xclbinutil already handles at its top level.

What has been tested and how, request additional testing if necessary

Built xclbinutil successfully for all six change sets. Recommend:

  • Fuzzing with crafted xclbins containing out-of-bounds mpo_* offsets and array counts, and verifying xclbinutil throws rather than leaking heap data.
  • Testing --add-pskernel with a path containing shell metacharacters on a Boost < 1.64 build and verifying no injection occurs.
  • AddressSanitizer build to confirm no residual OOB reads remain.

Documentation impact (if any)

None.

stsoe and others added 7 commits August 6, 2026 08:22
…ction parsers

#### Problem solved by the commit
Five section parsers passed fixed-size embedded char/uint8_t arrays from
wire-format structs directly to boost::format("%s"), which calls strlen
internally. If a crafted xclbin fills the last array entry's string field
with no null terminator, strlen reads past the end of the heap-allocated
section buffer into adjacent memory.

Affected fields:
- ip_data::m_name (uint8_t[64]) in SectionIPLayout.cxx
- clock_freq::m_name (char[128]) in SectionClockFrequencyTopology.cxx
- mem_data::m_tag (unsigned char[16]) in SectionMemTopology.cxx
- mem_data::m_tag (unsigned char[16]) in SectionGroupTopology.cxx
- debug_ip_data::m_name (char[128]) in SectionDebugIPLayout.cxx

#### How problem was solved
Added XclBinUtilities::bounded_fixed_cstr<N>() template helper that
constructs a std::string from a fixed-size array using strnlen to
bound the read to N bytes. Applied at all primary put() call sites
and TRACE logging sites in all five parsers.

#### Risks (if any) associated the changes in the commit
Low. For well-formed xclbins the output is identical. For malformed
inputs, the string is now truncated at the field boundary rather than
reading past the allocation.

#### What has been tested and how
Built xclbinutil successfully.

#### Documentation impact (if any)
None

Signed-off-by: Soren Soe <2106410+stsoe@users.noreply.github.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…ionAIEPartition

#### Problem solved by the commit
SectionAIEPartition::writeAIEPartitionImage() delegated to helper
functions (populate_partition_info, populate_pre_cdo_groups,
populate_cdo_groups, populate_PDIs, write_pdi_image) that all used
wire-format offset and size fields from the xclbin without any bounds
checking against bufferSize. An attacker-controlled xclbin could set any
of these fields to cause heap OOB reads:

- aie_partition::aie_pdi.offset/size (array of aie_pdi structs)
- aie_partition_info::start_columns.offset/size (uint16_t array)
- aie_pdi::cdo_groups.offset/size (array of cdo_group structs)
- aie_pdi::pdi_image.offset/size (PDI image data, also written to disk)
- cdo_group::mpo_name (string offset, missing bounded_mpo_cstr)
- cdo_group::dpu_kernel_ids.offset/size (uint64_t array)
- cdo_group::pre_cdo_groups.offset/size (uint64_t array)

#### How problem was solved
Added bufferSize parameter to all helper functions and added
overflow-safe bounds checks before forming any pointer from a
wire-format offset+count pair:
  if (count > (bufferSize - offset) / sizeof(T))
    throw std::runtime_error(...)
Replaced raw pBase+mpo_name with bounded_mpo_cstr() for the CDO group
name. pdi_image validated in populate_PDIs before write_pdi_image is
called.

#### Risks (if any) associated the changes in the commit
Low. Rejects malformed xclbins that would previously cause heap OOB
reads or write attacker-controlled data to PDI output files.

#### What has been tested and how
Built xclbinutil successfully.

#### Documentation impact (if any)
None

Signed-off-by: Soren Soe <2106410+stsoe@users.noreply.github.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…parsers

#### Problem solved by the commit
Three integer arithmetic bugs where wire-format-controlled values were
used in calculations that could overflow or underflow before a bounds
comparison:

1. SectionMCS.cxx: m_chunk::m_offset (uint64_t) was added to a pointer
   before comparison. Pointer wraparound on x86-64 caused the bounds
   check to pass for attacker-supplied offsets near UINT64_MAX, enabling
   heap OOB reads and writes.

2. SectionDNACertificate.cxx: paddingSize was computed as
   (_sectionSize - signatureSizeBytes) - paddingOffset, which underflows
   to ~2^64 when paddingOffset > sectionSize - signatureSizeBytes. The
   guard at line 95 only checked dnaEntriesBitSize/8 <= _sectionSize but
   did not account for signature overhead, allowing the subsequent
   binaryBufferToHexString call to read ~2^64 bytes from the heap.

3. XclBinUtilities.cxx: seekg positions were computed as
   signatureOffset + signature.signedByOffset/signatureOffset using
   unsigned int arithmetic, which wraps to zero for large values before
   being widened to std::streamoff. Also added missing gcount() checks.
   Additionally, the MBG tag compression loop accessed
   memIndexVector[idx+1] without a bounds guard, causing OOB read on the
   last element.

#### How problem was solved
- SectionMCS: check m_offset and m_size directly in offset-space against
  _sectionSize before forming the pointer.
- SectionDNACertificate: tighten the guard to include signatureSizeBytes
  + sizeof(uint64_t) overhead; add explicit paddingOffset bounds check
  before subtraction.
- XclBinUtilities seekg: cast signatureOffset to uint64_t before adding.
- XclBinUtilities MBG loop: add idx+1 < size guard to inner while.

#### Risks (if any) associated the changes in the commit
Low. All changes tighten input validation; behavior for well-formed
inputs is unchanged.

#### What has been tested and how
Built xclbinutil successfully.

#### Documentation impact (if any)
None

Signed-off-by: Soren Soe <2106410+stsoe@users.noreply.github.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…nBinary

#### Problem solved by the commit
Section.cxx compared m_sectionSize (uint64_t) against UINT64_MAX — a
tautologically false check since a uint64_t can never exceed UINT64_MAX.
The check was intended to guard the subsequent cast to unsigned int
(m_bufferSize), but because it could never fire, any xclbin section
with m_sectionSize > UINT32_MAX was silently truncated: the 4-GiB+
value wrapped to a small number, a tiny buffer was allocated, and all
downstream consumers used the wrong buffer size without error. The same
dead-code pattern appeared in the JSON image path at line ~420.

#### How problem was solved
Replace `> UINT64_MAX` with `> UINT32_MAX` in both guard locations.
This correctly rejects any section whose size exceeds the unsigned int
representation used for m_bufferSize.

#### Risks (if any) associated the changes in the commit
Low. Sections larger than 4 GiB are not valid in practice and would
have been silently corrupted before. The new check throws explicitly
instead of silently truncating.

#### What has been tested and how
Built xclbinutil successfully.

#### Documentation impact (if any)
None

Signed-off-by: Soren Soe <2106410+stsoe@users.noreply.github.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…d stream reads

#### Problem solved by the commit
Two logic/robustness bugs:

1. XclBinClass.cxx: ptInterfaces[0] was accessed unconditionally after
   as_vector() without checking whether the vector was empty. A crafted
   xclbin with partition_metadata that has no "interfaces" entry, or with
   a non-PR mode xclbin (where the size > 1 check is skipped entirely),
   caused undefined behavior via std::vector::operator[] on an empty
   vector — likely a crash.

2. XclBinUtilities.cxx: istream::read() of the SignatureHeader struct
   had no gcount() check. A truncated file would leave the signature
   struct zero-initialized; the signedBySize/signatureSize fields would
   be zero, silently skipping the data reads and masking the corruption.

#### How problem was solved
1. Added `if (ptInterfaces.empty()) return;` guard before ptInterfaces[0].
2. Added gcount() check after the SignatureHeader read, consistent with
   the existing checks on the signedBy and signature data reads (which
   were added as part of the Cat 3 fix).

#### Risks (if any) associated the changes in the commit
Low. The empty-check early return is safe — no interface UUID update
occurs for xclbins with no interfaces node, which was already the
behavior for well-formed xclbins. The gcount() check rejects truncated
signature files that would previously have been silently misprocessed.

#### What has been tested and how
Built xclbinutil successfully.

#### Documentation impact (if any)
None

Signed-off-by: Soren Soe <2106410+stsoe@users.noreply.github.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…lities

#### Problem solved by the commit
The Boost < 1.64 fallback path in XclBinUtilities::exec() built a shell
command line by joining the cmd path and args vector with spaces and
passing the result directly to popen(). A user-controlled argument such
as an elfLibrary path (--add-pskernel) containing shell metacharacters
(e.g. '; rm -rf /') would be executed by the shell.

The Boost >= 1.64 path (boost::process::child) passes arguments as a
vector, bypassing the shell, so no injection is possible there.

#### How problem was solved
Added shell_quote() helper that wraps each argument in single quotes and
escapes any embedded single quotes as '"'"'. Applied to both the cmd
path and every element of args when building the cmdLine string for
popen(). This prevents shell interpretation of any characters in
user-controlled values regardless of their content.

#### Risks (if any) associated the changes in the commit
Low. The popen path is only compiled on systems with Boost < 1.64
(pre-2016). The quoting is conservative — paths with spaces or special
characters that previously failed will now work correctly.

#### What has been tested and how
Built xclbinutil successfully.

#### Documentation impact (if any)
None

Signed-off-by: Soren Soe <2106410+stsoe@users.noreply.github.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Soren Soe <2106410+stsoe@users.noreply.github.com>
Co-Authored-By: Claude <noreply@anthropic.com>
@stsoe
stsoe requested a review from xfreid as a code owner August 6, 2026 17:45
@stsoe stsoe changed the title Review Security review of xclbinutil Aug 6, 2026
@stsoe
stsoe merged commit ab1b6ac into Xilinx:master Aug 6, 2026
21 of 22 checks passed
@stsoe
stsoe deleted the review branch August 6, 2026 20:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants