Fix DDS Timeout issues in oss-fuzz by wiredfool · Pull Request #9404 · python-pillow/Pillow

A recent run of oss-fuzz here ended up entirely finding dds parser timeout issues. There were 40 or so of them.

(vpy313) erics@wf:~/test$ python -m line_profiler -rtmz profile_output.lprof
Timer unit: 1e-06 s

Total time: 194.62 s
File: /home/erics/vpy313/lib/python3.13/site-packages/PIL/DdsImagePlugin.py
Function: DdsRgbDecoder.decode at line 492

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
   492                                               @line_profiler.profile
   493                                               def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
   494         1          1.1      1.1      0.0          assert self.fd is not None
   495         1          0.9      0.9      0.0          bitcount, masks = self.args
   496                                           
   497                                                   # Some masks will be padded with zeros, e.g. R 0b11 G 0b1100
   498                                                   # Calculate how many zeros each mask is padded with
   499         1          0.6      0.6      0.0          mask_offsets = []
   500                                                   # And the maximum value of each channel without the padding
   501         1          0.7      0.7      0.0          mask_totals = []
   502         4          3.4      0.8      0.0          for mask in masks:
   503         3          1.5      0.5      0.0              offset = 0
   504         3          1.7      0.6      0.0              if mask != 0:
   505                                                           while mask >> (offset + 1) << (offset + 1) == mask:
   506                                                               offset += 1
   507         3          2.3      0.8      0.0              mask_offsets.append(offset)
   508         3          2.0      0.7      0.0              mask_totals.append(mask >> offset)
   509                                           
   510         1          1.5      1.5      0.0          data = bytearray()
   511         1          0.7      0.7      0.0          bytecount = bitcount // 8
   512         1          2.1      2.1      0.0          dest_length = self.state.xsize * self.state.ysize * len(masks)
   513  16777218    8494592.6      0.5      4.4          while len(data) < dest_length:
   514  16777217    9764454.4      0.6      5.0              value = int.from_bytes(self.fd.read(bytecount), "little")
   515  67108868   32003938.7      0.5     16.4              for i, mask in enumerate(masks):
   516  50331651   22649228.0      0.4     11.6                  masked_value = value & mask
   517                                                           # Remove the zero padding, and scale it to 8 bits
   518 100663302   79428755.0      0.8     40.8                  data += o8(
   519                                                               int(((masked_value >> mask_offsets[i]) / mask_totals[i]) * 255)
   520  50331651   21372037.4      0.4     11.0                      if mask_totals[i]
   521  50331651   20668863.3      0.4     10.6                      else 0
   522                                                           )
   523         1     238290.4 238290.4      0.1          self.set_as_raw(data)
   524         1          0.8      0.8      0.0          return -1, 0
(vpy313) erics@wf:~/test$ python -m line_profiler -rtmz profile_output.lprof
Timer unit: 1e-06 s

Total time: 175.299 s
File: /home/erics/vpy313/lib/python3.13/site-packages/PIL/DdsImagePlugin.py
Function: DdsRgbDecoder.decode at line 492

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
   492                                               @line_profiler.profile
   493                                               def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
   494         1          1.4      1.4      0.0          assert self.fd is not None
   495         1          0.7      0.7      0.0          bitcount, masks = self.args
   496                                           
   497                                                   # Some masks will be padded with zeros, e.g. R 0b11 G 0b1100
   498                                                   # Calculate how many zeros each mask is padded with
   499         1          0.5      0.5      0.0          mask_offsets = []
   500                                                   # And the maximum value of each channel without the padding
   501         1          0.6      0.6      0.0          mask_totals = []
   502         4          2.7      0.7      0.0          for mask in masks:
   503         3          1.4      0.5      0.0              offset = 0
   504         3          1.7      0.6      0.0              if mask != 0:
   505                                                           while mask >> (offset + 1) << (offset + 1) == mask:
   506                                                               offset += 1
   507         3          2.2      0.7      0.0              mask_offsets.append(offset)
   508         3          1.8      0.6      0.0              mask_total = mask >> offset
   509         3          1.6      0.5      0.0              if not mask_total:
   510         3          1.4      0.5      0.0                  mask_totals.append(0)
   511                                                       else:
   512                                                           mask_totals.append(255/mask_total)
   513                                           
   514         1          1.4      1.4      0.0          data = bytearray()
   515         1          0.8      0.8      0.0          bytecount = bitcount // 8
   516         1          1.5      1.5      0.0          dest_length = self.state.xsize * self.state.ysize * len(masks)
   517                                           #        consolidate_mask = zip(masks, mask_offsets, mask_totals)
   518  16777218    8314582.1      0.5      4.7          while len(data) < dest_length:
   519  16777217    9686548.8      0.6      5.5              value = int.from_bytes(self.fd.read(bytecount), "little")
   520  67108868   31127505.5      0.5     17.8              for i, mask in enumerate(masks):
   521  50331651   21402455.3      0.4     12.2                  masked_value = value & mask
   522                                                           # Remove the zero padding, and scale it to 8 bits
   523 100663302   81338723.3      0.8     46.4                  data += o8(
   524  50331651   23194260.0      0.5     13.2                      int((masked_value >> mask_offsets[i]) * mask_totals[i])
   525                                                           )
   526         1     234889.2 234889.2      0.1          self.set_as_raw(data)
   527         1          1.1      1.1      0.0          return -1, 0
Timer unit: 1e-06 s

Total time: 0.299102 s
File: /home/erics/vpy313/lib/python3.13/site-packages/PIL/DdsImagePlugin.py
Function: DdsRgbDecoder.decode at line 493

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
   493                                               @line_profiler.profile
   494                                               def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
   495         1          1.9      1.9      0.0          assert self.fd is not None
   496         1          0.9      0.9      0.0          bitcount, masks = self.args
   497                                           
   498                                                   # Some masks will be padded with zeros, e.g. R 0b11 G 0b1100
   499                                                   # Calculate how many zeros each mask is padded with
   500         1          0.5      0.5      0.0          mask_offsets = []
   501                                                   # And the maximum value of each channel without the padding
   502         1          0.7      0.7      0.0          mask_totals = []
   503         4          9.3      2.3      0.0          for mask in masks:
   504         3          2.1      0.7      0.0              offset = 0
   505         3          1.7      0.6      0.0              if mask != 0:
   506                                                           while mask >> (offset + 1) << (offset + 1) == mask:
   507                                                               offset += 1
   508         3          2.3      0.8      0.0              mask_offsets.append(offset)
   509         3          2.0      0.7      0.0              mask_total = mask >> offset
   510         3          1.5      0.5      0.0              if not mask_total:
   511         3          1.5      0.5      0.0                  mask_totals.append(0)
   512                                                       else:
   513                                                           mask_totals.append(255/mask_total)
   514                                           
   515         1          1.4      1.4      0.0          data = bytearray()
   516         1          0.8      0.8      0.0          bytecount = bitcount // 8
   517         1          1.5      1.5      0.0          dest_length = self.state.xsize * self.state.ysize * len(masks)
   518                                                   # consume the data
   519         1          0.5      0.5      0.0          has_more = True
   520         3          2.3      0.8      0.0          while len(data) < dest_length and has_more:
   521         2          2.6      1.3      0.0              chunk = self.fd.read(bytecount)
   522                                                       # work around BufferedIO not being seekable
   523         2          1.5      0.7      0.0              has_more = len(chunk) > 0
   524         2          2.8      1.4      0.0              value = int.from_bytes(chunk, "little")
   525         8          5.2      0.6      0.0              for i, mask, in enumerate(masks):
   526         6          3.1      0.5      0.0                  masked_value = value & mask
   527                                                           # Remove the zero padding, and scale it to 8 bits
   528        12         16.2      1.3      0.0                  data += o8(
   529         6          4.1      0.7      0.0                      int((masked_value >> mask_offsets[i]) * mask_totals[i])
   530                                                           )
   531                                           
   532                                                   # extra padding pixels -- always all 0
   533         1          0.6      0.6      0.0          if len(data) < dest_length:
   534         1          0.7      0.7      0.0              pixel = bytearray()
   535         1          1.4      1.4      0.0              pixel += o8(0)
   536         1          0.7      0.7      0.0              ct_bytes = dest_length - len(data)
   537         1      57766.5  57766.5     19.3              data += pixel * ct_bytes
   538                                           
   539                                           
   540         1     241264.5 241264.5     80.7          self.set_as_raw(data)
   541         1          1.2      1.2      0.0          return -1, 0