mirror of
https://git.proxmox.com/git/proxmox-spamassassin
synced 2025-04-28 12:19:37 +00:00
1055 lines
29 KiB
Perl
1055 lines
29 KiB
Perl
# <@LICENSE>
|
|
# Licensed to the Apache Software Foundation (ASF) under one or more
|
|
# contributor license agreements. See the NOTICE file distributed with
|
|
# this work for additional information regarding copyright ownership.
|
|
# The ASF licenses this file to you under the Apache License, Version 2.0
|
|
# (the "License"); you may not use this file except in compliance with
|
|
# the License. You may obtain a copy of the License at:
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
# </@LICENSE>
|
|
|
|
=head1 NAME
|
|
|
|
Mail::SpamAssassin::Plugin::OLEVBMacro - scan Office documents for evidence of OLE Macros or other exploits
|
|
|
|
=head1 SYNOPSIS
|
|
|
|
loadplugin Mail::SpamAssassin::Plugin::OLEVBMacro
|
|
|
|
ifplugin Mail::SpamAssassin::Plugin::OLEVBMacro
|
|
body OLEMACRO eval:check_olemacro()
|
|
describe OLEMACRO Attachment has an Office Macro
|
|
|
|
body OLEOBJ eval:check_oleobject()
|
|
describe OLEOBJ Attachment has an Ole Object
|
|
|
|
body OLERTF eval:check_olertfobject()
|
|
describe OLERTF Attachment has an Ole Rtf Object
|
|
|
|
body OLEMACRO_MALICE eval:check_olemacro_malice()
|
|
describe OLEMACRO_MALICE Potentially malicious Office Macro
|
|
|
|
body OLEMACRO_ENCRYPTED eval:check_olemacro_encrypted()
|
|
describe OLEMACRO_ENCRYPTED Has an Office doc that is encrypted
|
|
|
|
body OLEMACRO_RENAME eval:check_olemacro_renamed()
|
|
describe OLEMACRO_RENAME Has an Office doc that has been renamed
|
|
|
|
body OLEMACRO_ZIP_PW eval:check_olemacro_zip_password()
|
|
describe OLEMACRO_ZIP_PW Has an Office doc that is password protected in a zip
|
|
|
|
body OLEMACRO_CSV eval:check_olemacro_csv()
|
|
describe OLEMACRO_CSV Malicious csv file that tries to exec cmd.exe detected
|
|
|
|
body OLEMACRO_DOWNLOAD_EXE eval:check_olemacro_download_exe()
|
|
describe OLEMACRO_DOWNLOAD_EXE Malicious code inside the Office doc that tries to download a .exe file detected
|
|
|
|
body OLEMACRO_URI_TARGET eval:check_olemacro_redirect_uri()
|
|
describe OLEMACRO_URI_TARGET Uri inside an Office doc
|
|
|
|
body OLEMACRO_MHTML_TARGET eval:check_olemacro_mhtml_uri()
|
|
describe OLEMACRO_MHTML_TARGET Exploitable mhtml uri inside an Office doc
|
|
endif
|
|
|
|
=head1 DESCRIPTION
|
|
|
|
This plugin detects OLE Macros or other exploits inside Office documents
|
|
attached to emails. It can detect documents inside zip files as well as
|
|
encrypted documents.
|
|
|
|
=head1 REQUIREMENT
|
|
|
|
This plugin requires Archive::Zip and IO::String perl modules.
|
|
|
|
=head1 USER PREFERENCES
|
|
|
|
The following options can be used in both site-wide (C<local.cf>) and
|
|
user-specific (C<user_prefs>) configuration files to customize how
|
|
the module handles attached documents
|
|
|
|
=cut
|
|
|
|
package Mail::SpamAssassin::Plugin::OLEVBMacro;
|
|
use strict;
|
|
use warnings;
|
|
|
|
use Mail::SpamAssassin::Plugin;
|
|
use Mail::SpamAssassin::Util qw(compile_regexp get_part_details);
|
|
|
|
use constant HAS_ARCHIVE_ZIP => eval { require Archive::Zip; };
|
|
use constant HAS_IO_STRING => eval { require IO::String; };
|
|
|
|
BEGIN
|
|
{
|
|
eval{
|
|
Archive::Zip->import(qw( :ERROR_CODES :CONSTANTS ))
|
|
};
|
|
eval{
|
|
IO::String->import
|
|
};
|
|
}
|
|
|
|
use re 'taint';
|
|
|
|
use vars qw(@ISA);
|
|
@ISA = qw(Mail::SpamAssassin::Plugin);
|
|
|
|
our $VERSION = '4.00';
|
|
|
|
# https://www.openoffice.org/sc/compdocfileformat.pdf
|
|
# http://blog.rootshell.be/2015/01/08/searching-for-microsoft-office-files-containing-macro/
|
|
my $marker1 = "\xd0\xcf\x11\xe0";
|
|
my $marker2 = "\x00\x41\x74\x74\x72\x69\x62\x75\x74\x00";
|
|
# Office 2003 embedded ole
|
|
my $marker2a = "\x01\x00\x4f\x00\x6c\x00\x65\x00\x31\x00\x30\x00\x4e\x00\x61\x00";
|
|
# embedded object in rtf files (https://www.biblioscape.com/rtf15_spec.htm)
|
|
my $marker3 = "\x5c\x6f\x62\x6a\x65\x6d\x62";
|
|
my $marker4 = "\x5c\x6f\x62\x6a\x64\x61\x74";
|
|
my $marker5 = "\x5c\x20\x6f\x62\x6a\x64\x61\x74";
|
|
# Excel .xlsx encrypted package, thanks to Dan Bagwell for the sample
|
|
my $encrypted_marker = "\x45\x00\x6e\x00\x63\x00\x72\x00\x79\x00\x70\x00\x74\x00\x65\x00\x64\x00\x50\x00\x61\x00\x63\x00\x6b\x00\x61\x00\x67\x00\x65";
|
|
# Excel .xls marker present only on unencrypted files
|
|
my $workbook_marker = "\x57\x00\x6f\x00\x72\x00\x6b\x00\x62\x00\x6f\x00\x6f\x00\x6b\x00";
|
|
# .exe file downloaded from external website
|
|
my $exe_marker1 = "\x00(https?://[-a-z0-9+&@#/%?=~_|!:,.;]{5,1000}[-a-z0-9+&@#/%=~_|]{5,1000}\.(?:exe|cmd|bat))[\x06|\x00]";
|
|
my $exe_marker2 = "URLDownloadToFileA";
|
|
|
|
# CVE-2021-40444 marker
|
|
my $mhtml_marker1 = "^MHTML:HTP:\\1&";
|
|
my $mhtml_marker2 = "^mhtml:https?://";
|
|
|
|
# this code burps an ugly message if it fails, but that's redirected elsewhere
|
|
# AZ_OK is a constant exported by Archive::Zip
|
|
my $az_ok;
|
|
eval '$az_ok = AZ_OK';
|
|
|
|
# constructor: register the eval rule
|
|
sub new {
|
|
my $class = shift;
|
|
my $mailsaobject = shift;
|
|
|
|
# some boilerplate...
|
|
$class = ref($class) || $class;
|
|
my $self = $class->SUPER::new($mailsaobject);
|
|
bless ($self, $class);
|
|
|
|
$self->set_config($mailsaobject->{conf});
|
|
|
|
$self->register_eval_rule("check_olemacro", $Mail::SpamAssassin::Conf::TYPE_BODY_EVALS);
|
|
$self->register_eval_rule("check_oleobject", $Mail::SpamAssassin::Conf::TYPE_BODY_EVALS);
|
|
$self->register_eval_rule("check_olertfobject", $Mail::SpamAssassin::Conf::TYPE_BODY_EVALS);
|
|
$self->register_eval_rule("check_olemacro_csv", $Mail::SpamAssassin::Conf::TYPE_BODY_EVALS);
|
|
$self->register_eval_rule("check_olemacro_malice", $Mail::SpamAssassin::Conf::TYPE_BODY_EVALS);
|
|
$self->register_eval_rule("check_olemacro_renamed", $Mail::SpamAssassin::Conf::TYPE_BODY_EVALS);
|
|
$self->register_eval_rule("check_olemacro_encrypted", $Mail::SpamAssassin::Conf::TYPE_BODY_EVALS);
|
|
$self->register_eval_rule("check_olemacro_zip_password", $Mail::SpamAssassin::Conf::TYPE_BODY_EVALS);
|
|
$self->register_eval_rule("check_olemacro_download_exe", $Mail::SpamAssassin::Conf::TYPE_BODY_EVALS);
|
|
$self->register_eval_rule("check_olemacro_redirect_uri", $Mail::SpamAssassin::Conf::TYPE_BODY_EVALS);
|
|
$self->register_eval_rule("check_olemacro_mhtml_uri", $Mail::SpamAssassin::Conf::TYPE_BODY_EVALS);
|
|
|
|
# lower priority for add_uri_detail_list to work
|
|
$self->register_method_priority ("parsed_metadata", -1);
|
|
|
|
if (!HAS_ARCHIVE_ZIP) {
|
|
warn "OLEVBMacro: check_zip not supported, required module Archive::Zip missing\n";
|
|
}
|
|
if (!HAS_IO_STRING) {
|
|
warn "OLEVBMacro: check_macrotype_doc not supported, required module IO::String missing\n";
|
|
}
|
|
|
|
return $self;
|
|
}
|
|
|
|
sub dbg { my $msg = shift; Mail::SpamAssassin::Plugin::dbg("OLEVBMacro: $msg", @_); }
|
|
|
|
sub set_config {
|
|
my ($self, $conf) = @_;
|
|
my @cmds = ();
|
|
|
|
=over 4
|
|
|
|
=item olemacro_num_mime (default: 5)
|
|
|
|
Configure the maximum number of matching MIME parts (attachments) the plugin
|
|
will scan.
|
|
|
|
=back
|
|
|
|
=cut
|
|
|
|
push(@cmds, {
|
|
setting => 'olemacro_num_mime',
|
|
default => 5,
|
|
type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC,
|
|
});
|
|
|
|
=over 4
|
|
|
|
=item olemacro_num_zip (default: 8)
|
|
|
|
Configure the maximum number of matching files inside the zip to scan.
|
|
To disable zip scanning, set 0.
|
|
|
|
=back
|
|
|
|
=cut
|
|
|
|
push(@cmds, {
|
|
setting => 'olemacro_num_zip',
|
|
default => 8,
|
|
type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC,
|
|
});
|
|
|
|
=over 4
|
|
|
|
=item olemacro_zip_depth (default: 2)
|
|
|
|
Depth to recurse within zip files.
|
|
|
|
=back
|
|
|
|
=cut
|
|
|
|
push(@cmds, {
|
|
setting => 'olemacro_zip_depth',
|
|
default => 2,
|
|
type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC,
|
|
});
|
|
|
|
=over 4
|
|
|
|
=item olemacro_extended_scan ( 0 | 1 ) (default: 0)
|
|
|
|
Scan all files for potential office files and/or macros, the
|
|
C<olemacro_skip_exts> parameter will still be honored. This parameter is
|
|
off by default, this option is needed only to run
|
|
C<eval:check_olemacro_renamed> rule. If this is turned on consider
|
|
adjusting values for C<olemacro_num_mime> and C<olemacro_num_zip> and
|
|
prepare for more CPU overhead.
|
|
|
|
=back
|
|
|
|
=cut
|
|
|
|
push(@cmds, {
|
|
setting => 'olemacro_extended_scan',
|
|
default => 0,
|
|
type => $Mail::SpamAssassin::Conf::CONF_TYPE_BOOL,
|
|
});
|
|
|
|
=over 4
|
|
|
|
=item olemacro_prefer_contentdisposition ( 0 | 1 ) (default: 1)
|
|
|
|
Choose if the content-disposition header filename be preferred if ambiguity is encountered whilst trying to get filename.
|
|
|
|
=back
|
|
|
|
=cut
|
|
|
|
push(@cmds, {
|
|
setting => 'olemacro_prefer_contentdisposition',
|
|
default => 1,
|
|
type => $Mail::SpamAssassin::Conf::CONF_TYPE_BOOL,
|
|
});
|
|
|
|
=over 4
|
|
|
|
=item olemacro_max_file (default: 1024000)
|
|
|
|
Limit the amount of bytes that the plugin will decode and scan from the MIME
|
|
objects (attachments).
|
|
|
|
=back
|
|
|
|
=cut
|
|
|
|
push(@cmds, {
|
|
setting => 'olemacro_max_file',
|
|
default => 1024000,
|
|
type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC,
|
|
});
|
|
|
|
=over 4
|
|
|
|
=item olemacro_exts (default: (?:doc|docx|dot|pot|ppa|pps|ppt|rtf|sldm|xl|xla|xls|xlsx|xlt|xltx|xslb)$)
|
|
|
|
Set the case-insensitive regexp used to configure the extensions the plugin
|
|
targets for macro scanning.
|
|
|
|
=back
|
|
|
|
=cut
|
|
|
|
# https://blogs.msdn.microsoft.com/vsofficedeveloper/2008/05/08/office-2007-file-format-mime-types-for-http-content-streaming-2/
|
|
# https://technet.microsoft.com/en-us/library/ee309278(office.12).aspx
|
|
|
|
push(@cmds, {
|
|
setting => 'olemacro_exts',
|
|
default => qr/(?:doc|docx|dot|pot|ppa|pps|ppt|rtf|sldm|xl|xla|xls|xlsx|xlt|xltx|xslb)$/,
|
|
type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING,
|
|
code => sub {
|
|
my ($self, $key, $value, $line) = @_;
|
|
unless (defined $value && $value !~ /^$/) {
|
|
return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
|
|
}
|
|
my ($rec, $err) = compile_regexp($value, 0);
|
|
if (!$rec) {
|
|
dbg("config: invalid olemacro_exts '$value': $err");
|
|
return $Mail::SpamAssassin::Conf::INVALID_VALUE;
|
|
}
|
|
$self->{olemacro_exts} = $rec;
|
|
},
|
|
});
|
|
|
|
=over 4
|
|
|
|
=item olemacro_macro_exts (default: (?:docm|dotm|ppam|potm|ppst|ppsm|pptm|sldm|xlm|xlam|xlsb|xlsm|xltm|xps)$)
|
|
|
|
Set the case-insensitive regexp used to configure the extensions the plugin
|
|
treats as containing a macro.
|
|
|
|
=back
|
|
|
|
=cut
|
|
|
|
push(@cmds, {
|
|
setting => 'olemacro_macro_exts',
|
|
default => qr/(?:docm|dotm|ppam|potm|ppst|ppsm|pptm|sldm|xlm|xlam|xlsb|xlsm|xltm|xps)$/,
|
|
type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING,
|
|
code => sub {
|
|
my ($self, $key, $value, $line) = @_;
|
|
unless (defined $value && $value !~ /^$/) {
|
|
return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
|
|
}
|
|
my ($rec, $err) = compile_regexp($value, 0);
|
|
if (!$rec) {
|
|
dbg("config: invalid olemacro_macro_exts '$value': $err");
|
|
return $Mail::SpamAssassin::Conf::INVALID_VALUE;
|
|
}
|
|
$self->{olemacro_macro_exts} = $rec;
|
|
},
|
|
});
|
|
|
|
=over 4
|
|
|
|
=item olemacro_skip_exts (default: (?:dotx|potx|ppsx|pptx|sldx)$)
|
|
|
|
Set the case-insensitive regexp used to configure extensions for the plugin
|
|
to skip entirely, these should only be guaranteed macro free files.
|
|
|
|
=back
|
|
|
|
=cut
|
|
|
|
push(@cmds, {
|
|
setting => 'olemacro_skip_exts',
|
|
default => qr/(?:dotx|potx|ppsx|pptx|sldx)$/,
|
|
type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING,
|
|
code => sub {
|
|
my ($self, $key, $value, $line) = @_;
|
|
unless (defined $value && $value !~ /^$/) {
|
|
return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
|
|
}
|
|
my ($rec, $err) = compile_regexp($value, 0);
|
|
if (!$rec) {
|
|
dbg("config: invalid olemacro_skip_exts '$value': $err");
|
|
return $Mail::SpamAssassin::Conf::INVALID_VALUE;
|
|
}
|
|
$self->{olemacro_skip_exts} = $rec;
|
|
},
|
|
});
|
|
|
|
=over 4
|
|
|
|
=item olemacro_skip_ctypes (default: ^(?:text\/))
|
|
|
|
Set the case-insensitive regexp used to configure content types for the
|
|
plugin to skip entirely, these should only be guaranteed macro free.
|
|
|
|
=back
|
|
|
|
=cut
|
|
|
|
push(@cmds, {
|
|
setting => 'olemacro_skip_ctypes',
|
|
default => qr/^(?:text\/)/,
|
|
type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING,
|
|
code => sub {
|
|
my ($self, $key, $value, $line) = @_;
|
|
unless (defined $value && $value !~ /^$/) {
|
|
return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
|
|
}
|
|
my ($rec, $err) = compile_regexp($value, 0);
|
|
if (!$rec) {
|
|
dbg("config: invalid olemacro_skip_ctypes '$value': $err");
|
|
return $Mail::SpamAssassin::Conf::INVALID_VALUE;
|
|
}
|
|
$self->{olemacro_skip_ctypes} = $rec;
|
|
},
|
|
});
|
|
|
|
=over 4
|
|
|
|
=item olemacro_zips (default: (?:zip)$)
|
|
|
|
Set the case-insensitive regexp used to configure extensions for the plugin
|
|
to target as zip files, files listed in configs above are also tested for zip.
|
|
|
|
=back
|
|
|
|
=cut
|
|
|
|
push(@cmds, {
|
|
setting => 'olemacro_zips',
|
|
default => qr/(?:zip)$/,
|
|
type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING,
|
|
code => sub {
|
|
my ($self, $key, $value, $line) = @_;
|
|
unless (defined $value && $value !~ /^$/) {
|
|
return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
|
|
}
|
|
my ($rec, $err) = compile_regexp($value, 0);
|
|
if (!$rec) {
|
|
dbg("config: invalid olemacro_zips '$value': $err");
|
|
return $Mail::SpamAssassin::Conf::INVALID_VALUE;
|
|
}
|
|
$self->{olemacro_zips} = $rec;
|
|
},
|
|
});
|
|
|
|
=over 4
|
|
|
|
=item olemacro_download_marker (default: (?:cmd(?:\.exe)? \/c ms\^h\^ta ht\^tps?:\/\^\/))
|
|
|
|
Set the case-insensitive regexp used to match the script used to
|
|
download files from the Office document.
|
|
|
|
=back
|
|
|
|
=cut
|
|
|
|
push(@cmds, {
|
|
setting => 'olemacro_download_marker',
|
|
default => qr/(?:cmd(?:\.exe)? \/c ms\^h\^ta ht\^tps?:\/\^\/)/,
|
|
type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING,
|
|
code => sub {
|
|
my ($self, $key, $value, $line) = @_;
|
|
unless (defined $value && $value !~ /^$/) {
|
|
return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
|
|
}
|
|
my ($rec, $err) = compile_regexp($value, 0);
|
|
if (!$rec) {
|
|
dbg("config: invalid olemacro_download_marker '$value': $err");
|
|
return $Mail::SpamAssassin::Conf::INVALID_VALUE;
|
|
}
|
|
$self->{olemacro_download_marker} = $rec;
|
|
},
|
|
});
|
|
|
|
$conf->{parser}->register_commands(\@cmds);
|
|
}
|
|
|
|
sub parsed_metadata {
|
|
my ($self, $opts) = @_;
|
|
|
|
_check_attachments($opts->{permsgstatus});
|
|
}
|
|
|
|
sub check_olemacro {
|
|
my ($self, $pms) = @_;
|
|
|
|
return $pms->{olemacro_exists} ? 1 : 0;
|
|
}
|
|
|
|
sub check_oleobject {
|
|
my ($self, $pms) = @_;
|
|
|
|
return $pms->{oleobject_exists} ? 1 : 0;
|
|
}
|
|
|
|
sub check_olertfobject {
|
|
my ($self, $pms) = @_;
|
|
|
|
return $pms->{olertfobject_exists} ? 1 : 0;
|
|
}
|
|
|
|
sub check_olemacro_csv {
|
|
my ($self, $pms) = @_;
|
|
|
|
return $pms->{olemacro_csv} ? 1 : 0;
|
|
}
|
|
|
|
sub check_olemacro_malice {
|
|
my ($self, $pms) = @_;
|
|
|
|
return $pms->{olemacro_malice} ? 1 : 0;
|
|
}
|
|
|
|
sub check_olemacro_renamed {
|
|
my ($self, $pms) = @_;
|
|
|
|
return $pms->{olemacro_renamed} ? 1 : 0;
|
|
}
|
|
|
|
sub check_olemacro_encrypted {
|
|
my ($self, $pms) = @_;
|
|
|
|
return $pms->{olemacro_encrypted} ? 1 : 0;
|
|
}
|
|
|
|
sub check_olemacro_zip_password {
|
|
my ($self, $pms) = @_;
|
|
|
|
return $pms->{olemacro_zip_password} ? 1 : 0;
|
|
}
|
|
|
|
sub check_olemacro_download_exe {
|
|
my ($self, $pms) = @_;
|
|
|
|
return $pms->{olemacro_download_exe} ? 1 : 0;
|
|
}
|
|
|
|
sub check_olemacro_redirect_uri {
|
|
my ($self, $pms) = @_;
|
|
|
|
if (exists $pms->{olemacro_redirect_uri}) {
|
|
my $rulename = $pms->get_current_eval_rule_name();
|
|
$pms->test_log($_, $rulename) foreach (keys %{$pms->{olemacro_redirect_uri}});
|
|
return 1;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
sub check_olemacro_mhtml_uri {
|
|
my ($self, $pms) = @_;
|
|
|
|
if (exists $pms->{olemacro_mhtml_uri}) {
|
|
my $rulename = $pms->get_current_eval_rule_name();
|
|
$pms->test_log($_, $rulename) foreach (keys %{$pms->{olemacro_mhtml_uri}});
|
|
return 1;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
sub _check_attachments {
|
|
my ($pms) = @_;
|
|
|
|
my $conf = $pms->{conf};
|
|
my $mimec = 0;
|
|
|
|
foreach my $part ($pms->{msg}->find_parts(qr/./, 1)) {
|
|
next if $part->{type} =~ /$conf->{olemacro_skip_ctypes}/i;
|
|
|
|
my ($ctt, $ctd, $cte, $name) = get_part_details($pms, $part, $conf->{olemacro_prefer_contentdisposition});
|
|
next unless defined $ctt;
|
|
next if $name eq '';
|
|
|
|
if ($name =~ /$conf->{olemacro_skip_exts}/i) {
|
|
dbg("Skipping file \"$name\" (olemacro_skip_exts)");
|
|
next;
|
|
}
|
|
|
|
my $data = $part->decode($conf->{olemacro_max_file});
|
|
if (!defined $data || $data eq '') {
|
|
dbg("Skipping empty file \"$name\"");
|
|
next;
|
|
}
|
|
|
|
# csv
|
|
if ($name =~ /\.csv$/i && $conf->{eval_to_rule}->{check_olemacro_csv}) {
|
|
dbg("Checking csv file \"$name\" for exploits");
|
|
_check_csv($pms, $name, $data);
|
|
}
|
|
|
|
# zip extensions
|
|
if ($name =~ /$conf->{olemacro_zips}/i) {
|
|
dbg("Found zip attachment with name \"$name\"");
|
|
_check_zip($pms, $name, $data);
|
|
}
|
|
# macro extensions
|
|
elsif ($name =~ /$conf->{olemacro_macro_exts}/i) {
|
|
dbg("Found macrotype attachment with name \"$name\"");
|
|
$pms->{olemacro_exists} = 1;
|
|
_check_encrypted_doc($pms, $name, $data);
|
|
_check_macrotype_doc($pms, $name, $data);
|
|
_check_download_marker($pms, $name, $data);
|
|
}
|
|
# normal extensions
|
|
elsif ($name =~ /$conf->{olemacro_exts}/i) {
|
|
dbg("Found attachment with name \"$name\"");
|
|
_check_encrypted_doc($pms, $name, $data);
|
|
_check_oldtype_doc($pms, $name, $data);
|
|
_check_macrotype_doc($pms, $name, $data);
|
|
_check_download_marker($pms, $name, $data);
|
|
}
|
|
# other files, check for rename?
|
|
elsif ($conf->{olemacro_extended_scan}) {
|
|
dbg("Extended scan for file \"$name\"");
|
|
my $renamed = 0;
|
|
$renamed = 1 if _is_office_doc($data);
|
|
$renamed = 1 if _check_encrypted_doc($pms, $name, $data);
|
|
$renamed = 1 if _check_oldtype_doc($pms, $name, $data);
|
|
$renamed = 1 if _check_macrotype_doc($pms, $name, $data);
|
|
if ($renamed) {
|
|
dbg("Found renamed office file \"$name\"");
|
|
$pms->{olemacro_renamed} = 1;
|
|
_check_download_marker($pms, $name, $data);
|
|
}
|
|
_check_zip($pms, $name, $data);
|
|
}
|
|
# nothing to check for this file
|
|
else {
|
|
next;
|
|
}
|
|
|
|
# something was checked, increment counter
|
|
if (++$mimec >= $conf->{olemacro_num_mime}) {
|
|
dbg('MIME limit reached');
|
|
last;
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
sub _check_download_marker {
|
|
my ($pms, $name, $data) = @_;
|
|
|
|
return 0 unless $pms->{conf}->{eval_to_rule}->{check_olemacro_download_exe};
|
|
|
|
if ((index($data, $exe_marker2) && $data =~ /$exe_marker1/i)
|
|
|| $data =~ /($pms->{conf}->{olemacro_download_marker})/i) {
|
|
my $uri = defined $1 ? $1 : $2;
|
|
dbg("Found URI that triggers a download in \"$name\": $uri");
|
|
$pms->{olemacro_download_exe} = 1;
|
|
return 1;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
sub _check_csv {
|
|
my ($pms, $name, $data) = @_;
|
|
|
|
if (index($data, 'cmd.exe') >= 0 &&
|
|
$data =~ /MSEXCEL\|.{1,20}Windows\\System32\\cmd\.exe/) {
|
|
dbg("Found cmd.exe exploit in \"$name\"");
|
|
$pms->{olemacro_csv} = 1;
|
|
}
|
|
}
|
|
|
|
sub _check_zip {
|
|
my ($pms, $name, $data, $depth) = @_;
|
|
|
|
return 0 if !$pms->{conf}->{olemacro_num_zip};
|
|
|
|
if (++$depth > $pms->{conf}->{olemacro_zip_depth}) {
|
|
dbg("Zip recursion limit exceeded");
|
|
return 0;
|
|
}
|
|
|
|
return 0 if !defined $data || $data eq '';
|
|
|
|
return 0 unless _is_zip_file($name, $data);
|
|
my $zip = _open_zip_handle($data);
|
|
return 0 unless defined $zip;
|
|
|
|
dbg("Zip \"$name\" opened");
|
|
|
|
my $conf = $pms->{conf};
|
|
my $filec = 0;
|
|
my @members = $zip->members();
|
|
foreach my $member (@members) {
|
|
my $name = $member->fileName();
|
|
my $data; # open zip member lazily
|
|
|
|
if ($name =~ /$conf->{olemacro_skip_exts}/i) {
|
|
dbg("Skipping zip member \"$name\" (olemacro_skip_exts)");
|
|
next;
|
|
}
|
|
|
|
if ($member->isEncrypted()) {
|
|
if ($name =~ /$conf->{olemacro_macro_exts}/i) {
|
|
dbg("Found macrotype zip member \"$name\"");
|
|
$pms->{olemacro_exists} = 1;
|
|
}
|
|
dbg("Zip member \"$name\" is encrypted (zip pw)");
|
|
$pms->{olemacro_zip_password} = 1;
|
|
next;
|
|
}
|
|
|
|
# csv
|
|
if ($name =~ /\.csv$/i && $conf->{eval_to_rule}->{check_olemacro_csv}) {
|
|
dbg("Checking zipped csv file \"$name\" for exploits");
|
|
if (!defined $data) {
|
|
($data, my $status) = $member->contents();
|
|
$data = undef unless $status == $az_ok;
|
|
}
|
|
_check_csv($pms, $name, $data) if defined $data;
|
|
}
|
|
|
|
# zip extensions
|
|
if ($name =~ /$conf->{olemacro_zips}/i) {
|
|
dbg("Found zippy zip member \"$name\"");
|
|
if (!defined $data) {
|
|
($data, my $status) = $member->contents();
|
|
$data = undef unless $status == $az_ok;
|
|
}
|
|
_check_zip($pms, $name, $data, $depth) if defined $data;
|
|
}
|
|
# macro extensions
|
|
elsif ($name =~ /$conf->{olemacro_macro_exts}/i) {
|
|
dbg("Found macrotype zip member \"$name\"");
|
|
$pms->{olemacro_exists} = 1;
|
|
if (!defined $data) {
|
|
($data, my $status) = $member->contents();
|
|
$data = undef unless $status == $az_ok;
|
|
}
|
|
if (defined $data) {
|
|
_check_encrypted_doc($pms, $name, $data);
|
|
_check_macrotype_doc($pms, $name, $data);
|
|
_check_download_marker($pms, $name, $data);
|
|
}
|
|
}
|
|
# normal extensions
|
|
elsif ($name =~ /$conf->{olemacro_exts}/i) {
|
|
dbg("Found zip member \"$name\"");
|
|
if (!defined $data) {
|
|
($data, my $status) = $member->contents();
|
|
$data = undef unless $status == $az_ok;
|
|
}
|
|
if (defined $data) {
|
|
_check_encrypted_doc($pms, $name, $data);
|
|
_check_oldtype_doc($pms, $name, $data);
|
|
_check_macrotype_doc($pms, $name, $data);
|
|
_check_download_marker($pms, $name, $data);
|
|
}
|
|
}
|
|
# other files, check for rename?
|
|
elsif ($conf->{olemacro_extended_scan}) {
|
|
dbg("Extended scan for zip member \"$name\"");
|
|
if (!defined $data) {
|
|
($data, my $status) = $member->contents();
|
|
$data = undef unless $status == $az_ok;
|
|
}
|
|
if (defined $data) {
|
|
my $renamed = 0;
|
|
$renamed = 1 if _is_office_doc($data);
|
|
$renamed = 1 if _check_encrypted_doc($pms, $name, $data);
|
|
$renamed = 1 if _check_oldtype_doc($pms, $name, $data);
|
|
$renamed = 1 if _check_macrotype_doc($pms, $name, $data);
|
|
if ($renamed) {
|
|
dbg("Found renamed office file \"$name\"");
|
|
$pms->{olemacro_renamed} = 1;
|
|
_check_download_marker($pms, $name, $data);
|
|
}
|
|
_check_zip($pms, $name, $data, $depth);
|
|
}
|
|
}
|
|
# nothing to check for this file
|
|
else {
|
|
next;
|
|
}
|
|
|
|
# something was checked, increment counter
|
|
if (++$filec >= $conf->{olemacro_num_zip}) {
|
|
dbg('Zip limit reached');
|
|
last;
|
|
}
|
|
}
|
|
|
|
return 1;
|
|
}
|
|
|
|
sub _open_zip_handle {
|
|
my ($data) = @_;
|
|
|
|
return unless HAS_ARCHIVE_ZIP && HAS_IO_STRING;
|
|
|
|
# open our archive from raw data
|
|
my $SH = IO::String->new($data);
|
|
Archive::Zip::setErrorHandler(\&_zip_error_handler);
|
|
my $zip = Archive::Zip->new();
|
|
if ($zip->readFromFileHandle($SH) != $az_ok) {
|
|
dbg("cannot read zipfile");
|
|
# as we cannot read it its not a zip (or too big/corrupted)
|
|
# so skip processing.
|
|
return;
|
|
}
|
|
|
|
return $zip;
|
|
}
|
|
|
|
sub _check_macrotype_doc {
|
|
my ($pms, $name, $data) = @_;
|
|
|
|
return if !defined $data || $data eq '';
|
|
|
|
return unless _is_zip_file($name, $data);
|
|
my $zip = _open_zip_handle($data);
|
|
return unless $zip;
|
|
|
|
my $is_doc = 0;
|
|
my $olemacro_exists = 0;
|
|
|
|
# https://www.decalage.info/vba_tools
|
|
# Consider macrofiles as lowercase, they are checked later with a case-insensitive method
|
|
my %macrofiles = (
|
|
'word/vbaproject.bin' => 'word2k7',
|
|
'macros/vba/_vba_project' => 'word97',
|
|
'xl/vbaproject.bin' => 'xl2k7',
|
|
'_vba_project_cur/vba/_vba_project' => 'xl97',
|
|
'ppt/vbaproject.bin' => 'ppt2k7',
|
|
);
|
|
|
|
my @members = $zip->members();
|
|
foreach my $member (@members){
|
|
my $name = lc $member->fileName();
|
|
if (exists $macrofiles{$name}) {
|
|
dbg("Found vba file \"$name\"");
|
|
$is_doc = 1;
|
|
$olemacro_exists = $pms->{olemacro_exists} = 1;
|
|
}
|
|
if (index($name, 'xl/embeddings/') == 0) {
|
|
dbg("Found ole file \"$name\"");
|
|
$is_doc = 1;
|
|
$pms->{oleobject_exists} = 1;
|
|
}
|
|
if ($name =~ /^word\/.{1,50}\.rtf\b/) {
|
|
dbg("Found ole rtf file \"$name\"");
|
|
$is_doc = 1;
|
|
$pms->{olertfobject_exists} = 1;
|
|
}
|
|
}
|
|
|
|
# Look for a member named [Content_Types].xml and do checks
|
|
if (my $ctypesxml = $zip->memberNamed('[Content_Types].xml')) {
|
|
dbg('Found [Content_Types].xml file');
|
|
$is_doc = 1;
|
|
if (!$pms->{olemacro_exists}) {
|
|
my ($data, $status) = $ctypesxml->contents();
|
|
if ($status == $az_ok && _check_ctype_xml($data)) {
|
|
$pms->{olemacro_exists} = 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
my @rels = $zip->membersMatching('.*\.rels');
|
|
foreach my $rel (@rels) {
|
|
dbg("Found \"".$rel->fileName."\" configuration file");
|
|
my ($data, $status) = $rel->contents();
|
|
next unless $status == $az_ok;
|
|
my @relations = split(/Relationship\s/, $data);
|
|
$is_doc = 1 if @relations;
|
|
foreach my $rl (@relations) {
|
|
if ($rl =~ /Target=\"([^"]*)\".*?TargetMode=\"External\"/is) {
|
|
my $uri = $1;
|
|
if ($uri =~ /(?:$mhtml_marker1|$mhtml_marker2)/i) {
|
|
dbg("Found target mhtml uri: $uri");
|
|
if (keys %{$pms->{olemacro_mhtml_uri}} < 5) {
|
|
$pms->{olemacro_mhtml_uri}{$uri} = 1;
|
|
}
|
|
}
|
|
$uri =~ s/^mhtml://i;
|
|
if ($uri =~ /^https?:\/\//i) {
|
|
dbg("Found target uri: $uri");
|
|
if (!exists $pms->{olemacro_redirect_uri}{$uri}) {
|
|
if (keys %{$pms->{olemacro_redirect_uri}} < 10) {
|
|
$pms->add_uri_detail_list($uri);
|
|
$pms->{olemacro_redirect_uri}{$uri} = 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($olemacro_exists && _find_malice_bins($zip)) {
|
|
$pms->{olemacro_malice} = 1;
|
|
}
|
|
|
|
return $is_doc;
|
|
}
|
|
|
|
# Office 2003
|
|
sub _check_oldtype_doc {
|
|
my ($pms, $name, $data) = @_;
|
|
|
|
return 0 if !defined $data || $data eq '';
|
|
|
|
if (_check_markers($data)) {
|
|
$pms->{olemacro_exists} = 1;
|
|
if (_check_malice($data)) {
|
|
$pms->{olemacro_malice} = 1;
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
# Encrypted doc
|
|
sub _check_encrypted_doc {
|
|
my ($pms, $name, $data) = @_;
|
|
|
|
return 0 if !defined $data || $data eq '';
|
|
|
|
if (_is_encrypted_doc($data)) {
|
|
dbg("File \"$name\" is encrypted");
|
|
$pms->{olemacro_encrypted} = 1;
|
|
return 1;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
sub _is_encrypted_doc {
|
|
my ($data) = @_;
|
|
|
|
return 0 unless _is_office_doc($data);
|
|
|
|
#http://stackoverflow.com/questions/14347513/how-to-detect-if-a-word-document-is-password-protected-before-uploading-the-file/14347730#14347730
|
|
return 1 if $data =~ /(?:<encryption xmlns)/i;
|
|
my $tdata = substr($data, 0, 2000);
|
|
return 1 if index($tdata, $encrypted_marker) > -1;
|
|
$tdata =~ s/\\0/ /g;
|
|
return 1 if index($tdata, "E n c r y p t e d P a c k a g e") > -1;
|
|
return 0 if index($tdata, $workbook_marker) > -1;
|
|
return 1 if substr($data, 0x208, 1) eq "\xfe";
|
|
return 1 if substr($data, 0x214, 1) eq "\x2f";
|
|
return 1 if substr($data, 0x20B, 1) eq "\x13";
|
|
|
|
return 0;
|
|
}
|
|
|
|
sub _is_office_doc {
|
|
my ($data) = @_;
|
|
|
|
return 0 if !defined $data || $data eq '';
|
|
|
|
if (index($data, $marker1) == 0) {
|
|
return 1;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
sub _is_zip_file {
|
|
my ($name, $data) = @_;
|
|
|
|
if (index($data, 'PK') == 0 || $name =~ /\.zip$/i) {
|
|
return 1;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
sub _check_markers {
|
|
my ($data) = @_;
|
|
|
|
# Check for Office 2003 markers
|
|
if (index($data, $marker1) == 0) {
|
|
if (index($data, $marker2) > -1) {
|
|
dbg('Marker 1 & 2 found');
|
|
return 1;
|
|
}
|
|
if (index($data, $marker2a) > -1) {
|
|
dbg('Marker 1 & 2a found');
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
# Check for rtf markers
|
|
if (index($data, $marker3) > -1) {
|
|
dbg('Marker 3 found');
|
|
return 1;
|
|
}
|
|
|
|
if (index($data, $marker4) > -1) {
|
|
dbg('Marker 4 found');
|
|
return 1;
|
|
}
|
|
|
|
if (index($data, $marker5) > -1) {
|
|
dbg('Marker 5 found');
|
|
return 1;
|
|
}
|
|
|
|
# Check for Office 2007 markers
|
|
if (index($data, 'w:macrosPresent="yes"') > -1) {
|
|
dbg('XML macros marker found');
|
|
return 1;
|
|
}
|
|
|
|
if (index($data, 'vbaProject.bin.rels') > -1) {
|
|
dbg('XML macros marker found');
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
sub _find_malice_bins {
|
|
my ($zip) = @_;
|
|
|
|
my @binfiles = $zip->membersMatching('.*\.bin');
|
|
|
|
foreach my $member (@binfiles) {
|
|
my ($data, $status) = $member->contents();
|
|
next unless $status == $az_ok;
|
|
if (_check_malice($data)) {
|
|
return 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
sub _check_malice {
|
|
my ($data) = @_;
|
|
|
|
# https://www.greyhathacker.net/?p=872
|
|
if ($data =~ /(?:document|auto|workbook)_?open/i) {
|
|
dbg('Found potential malicious code');
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
sub _check_ctype_xml {
|
|
my ($data) = @_;
|
|
|
|
return if !defined $data || $data eq '';
|
|
|
|
# http://download.microsoft.com/download/D/3/3/D334A189-E51B-47FF-B0E8-C0479AFB0E3C/[MS-OFFMACRO].pdf
|
|
if ($data =~ /ContentType=["']application\/vnd\.ms-office\.vbaProject["']/i) {
|
|
dbg('Found VBA ref');
|
|
return 1;
|
|
}
|
|
if ($data =~ /macroEnabled/i) {
|
|
dbg('Found Macro Ref');
|
|
return 1;
|
|
}
|
|
if ($data =~ /application\/vnd\.ms-excel\.(?:intl)?macrosheet/i) {
|
|
dbg('Excel macrosheet found');
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
sub _zip_error_handler {
|
|
1;
|
|
}
|
|
|
|
# Version features
|
|
sub has_olemacro_redirect_uri { 1 }
|
|
sub has_olemacro_mhtml_uri { 1 }
|
|
sub has_olertfobject { 1 }
|
|
|
|
1;
|