--- /dev/null
+++ b/README_FILES/README.CVE-2024-28054
@@ -0,0 +1,54 @@
+# Problem description
+
+Emails which consist of multiple parts (`Content-Type: multipart/*`)
+incorporate boundary information stating at which point one part ends and the
+next part begins.
+
+A boundary is announced by an Content-Type header's `boundary` parameter. To
+our current knowledge, RFC2046 and RFC2045 do not explicitly specify how a
+parser should handle multiple boundary parameters that contain conflicting
+values. As a result, there is no canonical choice which of the values should or
+should not be used for mime part decomposition.
+
+It turns out that MIME::Parser from MIME-tools chooses the last `boundary`
+parameter of a Content-Type-header, while several mail user agents choose the
+first occuring one. As a consequence, Amavis will apply some of its routines to
+content that a receiving MUA will not see, and vice-versa will not apply them
+to content that the receiving MUA will see. Such routines are at least
+- the banned-files check, and
+- the virus check, unless
+  - Amavis feeds the whole email into the virus scanner, and
+  - the virus scanner implements its own email parsing that aligns with the
+    receiving MUA's parser implementation.
+
+MIME::Parser does not provide a choice which of multiple `boundary` parameters
+shall be used for parsing, but it will give feedback in such a case [1], which
+Amavis can react to.
+Emails with ambiguous content, like multiple `boundary` parameters as described
+above, will be categorized as `CC_UNCHECKED,3`, since Amavis has no information
+about the recipient's MUA's parser implementation.
+
+# Recommendation
+
+Legitimate emails are not expected to have ambiguous content, so an Amavis setup
+should treat them harshly. The new default configuration for `CC_UNCHECKED,3` is
+defanging:
+
+```
+$defang_by_ccat{CC_UNCHECKED.",3"} = 1; # ambiguous content (e.g. multipart boundary)
+```
+
+Another possibility would be quarantining, e.g. via
+
+```
+$quarantine_to_maps_by_ccat{CC_UNCHECKED.",3"} = [1];
+$quarantine_method_by_ccat{CC_UNCHECKED.",3"} = 'local:unchecked-ambiguous-%m';
+```
+
+and/or discarding/rejecting the email:
+
+```
+$final_destiny_maps_by_ccat{CC_UNCHECKED.",3"} = D_REJECT; # or D_DISCARD
+```
+
+[1] https://metacpan.org/release/DSKOLL/MIME-tools-5.514/changes
--- a/amavisd
+++ b/amavisd
@@ -1374,6 +1374,7 @@
     CC_UNCHECKED,      'Unchecked',
     CC_UNCHECKED.',1', 'UncheckedEncrypted',
     CC_UNCHECKED.',2', 'UncheckedOverLimits',
+    CC_UNCHECKED.',3', 'UncheckedAmbiguousContent',
     CC_BANNED,     'Banned',
     CC_VIRUS,      'Virus',
   );
@@ -1844,6 +1845,7 @@
     CC_BANNED,      'id=%n - BANNED: %F',
     CC_UNCHECKED.',1', 'id=%n - UNCHECKED: encrypted',
     CC_UNCHECKED.',2', 'id=%n - UNCHECKED: over limits',
+    CC_UNCHECKED.',3', 'id=%n - UNCHECKED: ambiguous content',
     CC_UNCHECKED,      'id=%n - UNCHECKED',
     CC_SPAM,        'id=%n - spam',
     CC_SPAMMY.',1', 'id=%n - spammy (tag3)',
@@ -9946,7 +9948,8 @@
 sub attributes        # a string of characters representing attributes
   { @_<2 ? shift->{attr}     : ($_[0]->{attr} = $_[1]) };
 
-sub attributes_add {  # U=undecodable, C=crypted, D=directory,S=special,L=link
+sub attributes_add {  # U=undecodable, C=crypted, B=ambiguous-content,
+                      # D=directory, S=special, L=link
   my $self = shift; my $a = $self->{attr}; $a = '' if !defined $a;
   for my $arg (@_) { $a .= $arg  if $arg ne '' && index($a,$arg) < 0 }
   $self->{attr} = $a;
@@ -10405,6 +10408,24 @@
   }
 }
 
+sub ambiguous_content {
+  my $entity = shift;
+  if ($entity->can('ambiguous_content')) {
+    return $entity->ambiguous_content;
+  } else {
+    return unless $entity->is_multipart;
+    my $content_type = $entity->head->get('Content-Type');
+    if ($content_type && $content_type =~ m{^multipart/\w+(.+)}x) {
+      my ($params, $num) = ($1, 0);
+      while ($params =~ m{\G ; \s+ (?<param>\w+) = (?: \w+ | "(?:\\.|[^"\\])*" )}gx) {
+        $num++ if lc($+{param}) eq 'boundary';
+      }
+      return $num > 1;
+    }
+    return;
+  }
+}
+
 # traverse MIME::Entity object depth-first,
 # extracting preambles and epilogues as extra (pseudo)parts, and
 # filling-in additional information into Amavis::Unpackers::Part objects
@@ -10419,6 +10440,7 @@
   if (!defined($body)) {  # a MIME container only contains parts, no bodypart
     # create pseudo-part objects for MIME containers (e.g. multipart/* )
     $part = Amavis::Unpackers::Part->new(undef,$parent_obj,1);
+    $part->attributes_add('B') if ambiguous_content($entity);
 #   $part->type_short('no-file');
     do_log(2, "%s %s Content-Type: %s", $part->base_name, $placement, $mt);
 
@@ -14572,16 +14594,18 @@
 
       $which_section = "parts_decode_ext";
       snmp_count('OpsDec');
-      my($any_encrypted,$over_levels);
-      ($hold, $any_undecipherable, $any_encrypted, $over_levels) =
+      my($any_encrypted,$over_levels,$ambiguous);
+      ($hold, $any_undecipherable, $any_encrypted, $over_levels, $ambiguous) =
         Amavis::Unpackers::decompose_mail($msginfo->mail_tempdir,
                                           $file_generator_object);
-      $any_undecipherable ||= ($any_encrypted || $over_levels);
+      $any_undecipherable ||= ($any_encrypted || $over_levels || $ambiguous);
       if ($any_undecipherable) {
         $msginfo->add_contents_category(CC_UNCHECKED,0);
         $msginfo->add_contents_category(CC_UNCHECKED,1) if $any_encrypted;
         $msginfo->add_contents_category(CC_UNCHECKED,2) if $over_levels;
+        $msginfo->add_contents_category(CC_UNCHECKED,3) if $ambiguous;
         for my $r (@{$msginfo->per_recip_data}) {
+          $r->add_contents_category(CC_UNCHECKED,3) if $ambiguous;
           next if $r->bypass_virus_checks;
           $r->add_contents_category(CC_UNCHECKED,0);
           $r->add_contents_category(CC_UNCHECKED,1) if $any_encrypted;
@@ -31643,7 +31667,7 @@
   my($tempdir,$file_generator_object) = @_;
 
   my $hold; my(@parts); my $depth = 1;
-  my($any_undecipherable, $any_encrypted, $over_levels) = (0,0,0);
+  my($any_undecipherable, $any_encrypted, $over_levels, $ambiguous) = (0,0,0,0);
   my $which_section = "parts_decode";
   # fetch all not-yet-visited part names, and start a new cycle
 TIER:
@@ -31705,13 +31729,14 @@
       if (defined $attr) {
         $any_undecipherable++  if index($attr, 'U') >= 0;
         $any_encrypted++       if index($attr, 'C') >= 0;
+        $ambiguous++           if index($attr, 'B') >= 0;
       }
     }
     last TIER  if defined $hold;
     $depth++;
   }
   section_time($which_section); prolong_timer($which_section);
-  ($hold, $any_undecipherable, $any_encrypted, $over_levels);
+  ($hold, $any_undecipherable, $any_encrypted, $over_levels, $ambiguous);
 }
 
 # Decompose one part
--- a/amavisd.conf
+++ b/amavisd.conf
@@ -145,6 +145,7 @@
 $defang_by_ccat{CC_BADH.",3"} = 1;  # NUL or CR character in header
 $defang_by_ccat{CC_BADH.",5"} = 1;  # header line longer than 998 characters
 $defang_by_ccat{CC_BADH.",6"} = 1;  # header field syntax error
+$defang_by_ccat{CC_UNCHECKED.",3"} = 1; # ambiguous content (e.g. multipart boundary)
 
 
 # OTHER MORE COMMON SETTINGS (defaults may suffice):
