bin/pdfcop in origami-1.2.7 vs bin/pdfcop in origami-2.0.0

- old
+ new

@@ -1,455 +1,445 @@ #!/usr/bin/env ruby =begin -= Author: - Guillaume Delugré <guillaume/at/security-labs.org> += Info + This is a PDF document filtering engine using Origami. + Security policies are based on a white list of PDF features. + Default policies details can be found in the default configuration file. + You can also add your own policy and activate it using the -p switch. -= Info: - This is a PDF document filtering engine using Origami. - Security policies are based on a white list of PDF features. - Default policies details can be found in the default configuration file. - You can also add your own policy and activate it using the -p switch. += License + Copyright (C) 2016 Guillaume Delugré. -= License: - Origami is free software: you can redistribute it and/or modify - it under the terms of the GNU Lesser General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. + Origami is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. - Origami is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Lesser General Public License for more details. + Origami is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. - You should have received a copy of the GNU Lesser General Public License - along with Origami. If not, see <http://www.gnu.org/licenses/>. + You should have received a copy of the GNU Lesser General Public License + along with Origami. If not, see <http://www.gnu.org/licenses/>. =end begin - require 'origami' + require 'origami' rescue LoadError - ORIGAMIDIR = "#{File.dirname(__FILE__)}/../lib" - $: << ORIGAMIDIR - require 'origami' + $: << File.join(__dir__, '../lib') + require 'origami' end -include Origami require 'optparse' require 'yaml' require 'rexml/document' require 'digest/md5' +require 'colorize' DEFAULT_CONFIG_FILE = "#{File.dirname(__FILE__)}/config/pdfcop.conf.yml" DEFAULT_POLICY = "standard" SECURITY_POLICIES = {} def load_config_file(path) - SECURITY_POLICIES.update(Hash.new(false).update YAML.load(File.read(path))) + SECURITY_POLICIES.update(Hash.new(false).update YAML.load(File.read(path))) end class OptParser - BANNER = <<USAGE + BANNER = <<USAGE Usage: #{$0} [options] <PDF-file> The PDF filtering engine. Scans PDF documents for malicious structures. -Bug reports or feature requests at: http://origami-pdf.googlecode.com/ +Bug reports or feature requests at: http://github.com/gdelugre/origami Options: USAGE - def self.parse(args) - options = {:colors => true} + def self.parse(args) + options = {colors: true} - opts = OptionParser.new do |opts| - opts.banner = BANNER + opts = OptionParser.new do |opts| + opts.banner = BANNER - opts.on("-o", "--output LOG_FILE", "Output log file (default STDOUT)") do |o| - options[:output_log] = o - end + opts.on("-o", "--output LOG_FILE", "Output log file (default STDOUT)") do |o| + options[:output_log] = o + end - opts.on("-c", "--config CONFIG_FILE", "Load security policies from given configuration file") do |cf| - options[:config_file] = cf - end + opts.on("-c", "--config CONFIG_FILE", "Load security policies from given configuration file") do |cf| + options[:config_file] = cf + end - opts.on("-p", "--policy POLICY_NAME", "Specify applied policy. Predefined policies: 'none', 'standard', 'strong', 'paranoid'") do |p| - options[:policy] = p - end + opts.on("-p", "--policy POLICY_NAME", "Specify applied policy. Predefined policies: 'none', 'standard', 'strong', 'paranoid'") do |p| + options[:policy] = p + end - opts.on("-n", "--no-color", "Suppress colored output") do - options[:colors] = false - end + opts.on("-n", "--no-color", "Turn off colorized output") do + options[:disable_colors] = true + end - opts.on_tail("-h", "--help", "Show this message") do - puts opts - exit - end - end - opts.parse!(args) + opts.on_tail("-h", "--help", "Show this message") do + puts opts + exit + end + end - options - end + opts.parse!(args) + + options + end end @options = OptParser.parse(ARGV) if @options.has_key?(:output_log) - LOGGER = File.open(@options[:output_log], "a+") + LOGGER = File.open(@options[:output_log], "a+") else - LOGGER = STDOUT + LOGGER = STDOUT end if not @options.has_key?(:policy) - @options[:policy] = DEFAULT_POLICY + @options[:policy] = DEFAULT_POLICY end +String.disable_colorization @options[:disable_colors] + load_config_file(@options[:config_file] || DEFAULT_CONFIG_FILE) unless SECURITY_POLICIES.has_key?("POLICY_#{@options[:policy].upcase}") - STDERR.puts "Undeclared policy `#{@options[:policy]}'" - exit(1) + abort "Undeclared policy `#{@options[:policy]}'" end if ARGV.empty? - STDERR.puts "Error: No filename was specified. #{$0} --help for details." - exit 1 + abort "Error: No filename was specified. #{$0} --help for details." else - TARGET = ARGV.shift + TARGET = ARGV.shift end -def log(str, color = Console::Colors::GREY) - if @options[:colors] - Console.colorprint("[#{Time.now}] ", Console::Colors::CYAN, LOGGER) - Console.colorprint(str, color, LOGGER) - else - LOGGER.print("[#{Time.now}] #{str}") - end - - LOGGER.puts +def log(str, color = :default) + LOGGER.puts("[#{Time.now}]".cyan + " #{str.colorize(color)}") end def reject(cause) - log("Document rejected by policy `#{@options[:policy]}', caused by #{cause.inspect}.", Console::Colors::RED) - exit(1) + log("Document rejected by policy `#{@options[:policy]}', caused by #{cause.inspect}.", :red) + abort end def check_rights(*required_rights) - current_rights = SECURITY_POLICIES["POLICY_#{@options[:policy].upcase}"] + current_rights = SECURITY_POLICIES["POLICY_#{@options[:policy].upcase}"] - reject(required_rights) if required_rights.any?{|right| current_rights[right.to_s] == false} + reject(required_rights) if required_rights.any?{|right| current_rights[right.to_s] == false} end def analyze_xfa_forms(xfa) - case xfa - when Array then - xml = "" - i = 0 - xfa.each do |packet| - if i % 2 == 1 - xml << packet.solve.data - end + case xfa + when Origami::Array then + xml = "" + i = 0 + xfa.each do |packet| + if i % 2 == 1 + xml << packet.solve.data + end - i = i + 1 - end - when Stream then - xml = xfa.data + i = i + 1 + end + when Origami::Stream then + xml = xfa.data else - reject("Malformed XFA dictionary") - end + reject("Malformed XFA dictionary") + end - xfadoc = REXML::Document.new(xml) - REXML::XPath.match(xfadoc, "//script").each do |script| - case script.attributes["contentType"] - when "application/x-formcalc" then - check_rights(:allowFormCalc) - else - check_rights(:allowJS) + xfadoc = REXML::Document.new(xml) + REXML::XPath.match(xfadoc, "//script").each do |script| + case script.attributes["contentType"] + when "application/x-formcalc" then + check_rights(:allowFormCalc) + else + check_rights(:allowJS) + end end - end end def analyze_annotation(annot, level = 0) - check_rights(:allowAnnotations) - - if annot.is_a?(Dictionary) and annot.has_key?(:Subtype) - case annot[:Subtype].solve.value - when :FileAttachment then - check_rights(:allowAttachments, :allowFileAttachmentAnnotation) + check_rights(:allowAnnotations) - when :Sound then - check_rights(:allowSoundAnnotation) - - when :Movie then - check_rights(:allowMovieAnnotation) - - when :Screen then - check_rights(:allowScreenAnnotation) + if annot.is_a?(Origami::Dictionary) and annot.has_key?(:Subtype) + case annot[:Subtype].solve.value + when :FileAttachment + check_rights(:allowAttachments, :allowFileAttachmentAnnotation) - when :Widget then - check_rights(:allowAcroforms) - - when :"3D" then - check_rights(:allow3DAnnotation) + when :Sound + check_rights(:allowSoundAnnotation) - # 3D annotation might pull in JavaScript for real-time driven behavior. - if annot.has_key?(:"3DD") - dd = annot[:"3DD"].solve - u3dstream = nil + when :Movie + check_rights(:allowMovieAnnotation) - case dd - when Stream then - u3dstream = dd - when Dictionary then - u3dstream = dd[:"3DD"] - end + when :Screen + check_rights(:allowScreenAnnotation) - if u3dstream and u3dstream.has_field?(:OnInstantiate) - check_rights(:allowJS) - - if annot.has_key?(:"3DA") # is 3d view instantiated automatically? - u3dactiv = annot[:"3DA"].solve - - check_rights(:allowJSAtOpening) if u3dactiv.is_a?(Dictionary) and (u3dactiv[:A] == :PO or u3dactiv[:A] == :PV) + when :Widget + check_rights(:allowAcroforms) + + when :"3D" + check_rights(:allow3DAnnotation) + + # 3D annotation might pull in JavaScript for real-time driven behavior. + if annot.has_key?(:"3DD") + dd = annot[:"3DD"].solve + u3dstream = nil + + case dd + when Origami::Stream + u3dstream = dd + when Origami::Dictionary + u3dstream = dd[:"3DD"] + end + + if u3dstream and u3dstream.has_field?(:OnInstantiate) + check_rights(:allowJS) + + if annot.has_key?(:"3DA") # is 3d view instantiated automatically? + u3dactiv = annot[:"3DA"].solve + + check_rights(:allowJSAtOpening) if u3dactiv.is_a?(Origami::Dictionary) and (u3dactiv[:A] == :PO or u3dactiv[:A] == :PV) + end + end end - end - end - when :RichMedia then - check_rights(:allowRichMediaAnnotation) + when :RichMedia + check_rights(:allowRichMediaAnnotation) + end end - end end def analyze_page(page, level = 0) - section_prefix = " " * 2 * level + ">" * (level + 1) - log(section_prefix + " Inspecting page...") + section_prefix = " " * 2 * level + ">" * (level + 1) + log(section_prefix + " Inspecting page...") - text_prefix = " " * 2 * (level + 1) + "." * (level + 1) - if page.is_a?(Dictionary) - - # - # Checking page additional actions. - # - if page.has_key?(:AA) - if page.AA.is_a?(Dictionary) - log(text_prefix + " Page has an action dictionary.") - - aa = PageAdditionalActions.new(page.AA); aa.parent = page.AA.parent - analyze_action(aa.O, true, level + 1) if aa.has_key?(:O) - analyze_action(aa.C, false, level + 1) if aa.has_key?(:C) - end - end + text_prefix = " " * 2 * (level + 1) + "." * (level + 1) + if page.is_a?(Origami::Dictionary) + # + # Checking page additional actions. + # + if page.has_key?(:AA) + if page.AA.is_a?(Origami::Dictionary) + log(text_prefix + " Page has an action dictionary.") - # - # Looking for page annotations. - # - page.each_annot do |annot| - analyze_annotation(annot, level + 1) + aa = Origami::Page::AdditionalActions.new(page.AA); aa.parent = page.AA.parent + analyze_action(aa.O, true, level + 1) if aa.has_key?(:O) + analyze_action(aa.C, false, level + 1) if aa.has_key?(:C) + end + end + + # + # Looking for page annotations. + # + page.each_annotation do |annot| + analyze_annotation(annot, level + 1) + end end - end end def analyze_action(action, triggered_at_opening, level = 0) - section_prefix = " " * 2 * level + ">" * (level + 1) - log(section_prefix + " Inspecting action...") - - text_prefix = " " * 2 * (level + 1) + "." * (level + 1) - if action.is_a?(Dictionary) - log(text_prefix + " Found #{action[:S]} action.") - type = action[:S].is_a?(Reference) ? action[:S].solve : action[:S] + section_prefix = " " * 2 * level + ">" * (level + 1) + log(section_prefix + " Inspecting action...") - case type.value - when :JavaScript - check_rights(:allowJS) - check_rights(:allowJSAtOpening) if triggered_at_opening + text_prefix = " " * 2 * (level + 1) + "." * (level + 1) + if action.is_a?(Origami::Dictionary) + log(text_prefix + " Found #{action[:S]} action.") + type = action[:S].is_a?(Origami::Reference) ? action[:S].solve : action[:S] - when :Launch - check_rights(:allowLaunchAction) + case type.value + when :JavaScript + check_rights(:allowJS) + check_rights(:allowJSAtOpening) if triggered_at_opening - when :Named - check_rights(:allowNamedAction) + when :Launch + check_rights(:allowLaunchAction) - when :GoTo - check_rights(:allowGoToAction) - dest = action[:D].is_a?(Reference) ? action[:D].solve : action[:D] - if dest.is_a?(Array) and dest.length > 0 and dest.first.is_a?(Reference) - dest_page = dest.first.solve - if dest_page.is_a?(Page) - log(text_prefix + " Destination page found.") - analyze_page(dest_page, level + 1) - end - end + when :Named + check_rights(:allowNamedAction) - when :GoToE - check_rights(:allowAttachments,:allowGoToEAction) - - when :GoToR - check_rights(:allowGoToRAction) + when :GoTo + check_rights(:allowGoToAction) + dest = action[:D].is_a?(Origami::Reference) ? action[:D].solve : action[:D] + if dest.is_a?(Origami::Array) and dest.length > 0 and dest.first.is_a?(Origami::Reference) + dest_page = dest.first.solve + if dest_page.is_a?(Origami::Page) + log(text_prefix + " Destination page found.") + analyze_page(dest_page, level + 1) + end + end - when :Thread - check_rights(:allowGoToRAction) if action.has_key?(:F) + when :GoToE + check_rights(:allowAttachments,:allowGoToEAction) - when :URI - check_rights(:allowURIAction) + when :GoToR + check_rights(:allowGoToRAction) - when :SubmitForm - check_rights(:allowAcroForms,:allowSubmitFormAction) + when :Thread + check_rights(:allowGoToRAction) if action.has_key?(:F) - when :ImportData - check_rights(:allowAcroForms,:allowImportDataAction) + when :URI + check_rights(:allowURIAction) - when :Rendition - check_rights(:allowScreenAnnotation,:allowRenditionAction) + when :SubmitForm + check_rights(:allowAcroForms,:allowSubmitFormAction) - when :Sound - check_rights(:allowSoundAnnotation,:allowSoundAction) + when :ImportData + check_rights(:allowAcroForms,:allowImportDataAction) - when :Movie - check_rights(:allowMovieAnnotation,:allowMovieAction) + when :Rendition + check_rights(:allowScreenAnnotation,:allowRenditionAction) - when :RichMediaExecute - check_rights(:allowRichMediaAnnotation,:allowRichMediaAction) + when :Sound + check_rights(:allowSoundAnnotation,:allowSoundAction) - when :GoTo3DView - check_rights(:allow3DAnnotation,:allowGoTo3DAction) - end + when :Movie + check_rights(:allowMovieAnnotation,:allowMovieAction) - if action.has_key?(:Next) - log(text_prefix + "This action is chained to another action!") - check_rights(:allowChainedActions) - analyze_action(action.Next) + when :RichMediaExecute + check_rights(:allowRichMediaAnnotation,:allowRichMediaAction) + + when :GoTo3DView + check_rights(:allow3DAnnotation,:allowGoTo3DAction) + end + + if action.has_key?(:Next) + log(text_prefix + "This action is chained to another action!") + check_rights(:allowChainedActions) + analyze_action(action.Next) + end + + elsif action.is_a?(Origami::Array) + dest = action + if dest.length > 0 and dest.first.is_a?(Origami::Reference) + dest_page = dest.first.solve + if dest_page.is_a?(Origami::Page) + log(text_prefix + " Destination page found.") + check_rights(:allowGoToAction) + analyze_page(dest_page, level + 1) + end + end end - elsif action.is_a?(Array) - dest = action - if dest.length > 0 and dest.first.is_a?(Reference) - dest_page = dest.first.solve - if dest_page.is_a?(Page) - log(text_prefix + " Destination page found.") - check_rights(:allowGoToAction) - analyze_page(dest_page, level + 1) - end - end - end end begin - log("PDFcop is running on target `#{TARGET}', policy = `#{@options[:policy]}'", Console::Colors::GREEN) - log(" File size: #{File.size(TARGET)} bytes", Console::Colors::MAGENTA) - log(" MD5: #{Digest::MD5.hexdigest(File.read(TARGET))}", Console::Colors::MAGENTA) - - @pdf = PDF.read(TARGET, - :verbosity => Parser::VERBOSE_QUIET, - :ignore_errors => SECURITY_POLICIES["POLICY_#{@options[:policy].upcase}"]['allowParserErrors'] - ) + log("PDFcop is running on target `#{TARGET}', policy = `#{@options[:policy]}'", :green) + log(" File size: #{File.size(TARGET)} bytes", :magenta) + log(" MD5: #{Digest::MD5.hexdigest(File.read(TARGET))}", :magenta) - log("> Inspecting document structure...", Console::Colors::YELLOW) - if @pdf.is_encrypted? - log(" . Encryption = YES") - check_rights(:allowEncryption) - end + @pdf = Origami::PDF.read(TARGET, + verbosity: Origami::Parser::VERBOSE_QUIET, + ignore_errors: SECURITY_POLICIES["POLICY_#{@options[:policy].upcase}"]['allowParserErrors'] + ) - log("> Inspecting document catalog...", Console::Colors::YELLOW) - catalog = @pdf.Catalog - reject("Invalid document catalog") unless catalog.is_a?(Catalog) + log("> Inspecting document structure...", :yellow) + if @pdf.encrypted? + log(" . Encryption = YES") + check_rights(:allowEncryption) + end - if catalog.has_key?(:OpenAction) - log(" . OpenAction entry = YES") - check_rights(:allowOpenAction) - action = catalog.OpenAction - analyze_action(action, true, 1) - end + log("> Inspecting document catalog...", :yellow) + catalog = @pdf.Catalog + reject("Invalid document catalog") unless catalog.is_a?(Origami::Catalog) - if catalog.has_key?(:AA) - if catalog.AA.is_a?(Dictionary) - aa = CatalogAdditionalActions.new(catalog.AA); aa.parent = catalog; - log(" . Additional actions dictionary = YES") - analyze_action(aa.WC, false, 1) if aa.has_key?(:WC) - analyze_action(aa.WS, false, 1) if aa.has_key?(:WS) - analyze_action(aa.DS, false, 1) if aa.has_key?(:DS) - analyze_action(aa.WP, false, 1) if aa.has_key?(:WP) - analyze_action(aa.DP, false, 1) if aa.has_key?(:DP) + if catalog.has_key?(:OpenAction) + log(" . OpenAction entry = YES") + check_rights(:allowOpenAction) + action = catalog.OpenAction + analyze_action(action, true, 1) end - end - if catalog.has_key?(:AcroForm) - acroform = catalog.AcroForm - if acroform.is_a?(Dictionary) - log(" . AcroForm = YES") - check_rights(:allowAcroForms) - if acroform.has_key?(:XFA) - log(" . XFA = YES") - check_rights(:allowXFAForms) + if catalog.has_key?(:AA) + if catalog.AA.is_a?(Origami::Dictionary) + aa = Origami::CatalogAdditionalActions.new(catalog.AA); aa.parent = catalog; + log(" . Additional actions dictionary = YES") + analyze_action(aa.WC, false, 1) if aa.has_key?(:WC) + analyze_action(aa.WS, false, 1) if aa.has_key?(:WS) + analyze_action(aa.DS, false, 1) if aa.has_key?(:DS) + analyze_action(aa.WP, false, 1) if aa.has_key?(:WP) + analyze_action(aa.DP, false, 1) if aa.has_key?(:DP) + end + end - analyze_xfa_forms(acroform[:XFA].solve) - end + if catalog.has_key?(:AcroForm) + acroform = catalog.AcroForm + if acroform.is_a?(Origami::Dictionary) + log(" . AcroForm = YES") + check_rights(:allowAcroForms) + if acroform.has_key?(:XFA) + log(" . XFA = YES") + check_rights(:allowXFAForms) + + analyze_xfa_forms(acroform[:XFA].solve) + end + end end - end - log("> Inspecting JavaScript names directory...", Console::Colors::YELLOW) - unless @pdf.ls_names(Names::Root::JAVASCRIPT).empty? - check_rights(:allowJS) - check_rights(:allowJSAtOpening) - end + log("> Inspecting JavaScript names directory...", :yellow) + if @pdf.each_named_script.any? + check_rights(:allowJS) + check_rights(:allowJSAtOpening) + end - log("> Inspecting attachment names directory...", Console::Colors::YELLOW) - unless @pdf.ls_names(Names::Root::EMBEDDEDFILES).empty? - check_rights(:allowAttachments) - end + log("> Inspecting attachment names directory...", :yellow) + if @pdf.each_attachment.any? + check_rights(:allowAttachments) + end - log("> Inspecting document pages...", Console::Colors::YELLOW) - @pdf.each_page do |page| - analyze_page(page, 1) - end + log("> Inspecting document pages...", :yellow) + @pdf.each_page do |page| + analyze_page(page, 1) + end - log("> Inspecting document streams...", Console::Colors::YELLOW) - @pdf.indirect_objects.find_all{|obj| obj.is_a?(Stream)}.each do |stream| - if stream.dictionary.has_key?(:Filter) - filters = stream.Filter - filters = [ filters ] if filters.is_a?(Name) + log("> Inspecting document streams...", :yellow) + @pdf.indirect_objects.find_all{|obj| obj.is_a?(Origami::Stream)}.each do |stream| + if stream.dictionary.has_key?(:Filter) + filters = stream.Filter + filters = [ filters ] if filters.is_a?(Origami::Name) - if filters.is_a?(Array) - filters.each do |filter| - case filter.value - when :ASCIIHexDecode - check_rights(:allowASCIIHexFilter) - when :ASCII85Decode - check_rights(:allowASCII85Filter) - when :LZWDecode - check_rights(:allowLZWFilter) - when :FlateDecode - check_rights(:allowFlateDecode) - when :RunLengthDecode - check_rights(:allowRunLengthFilter) - when :CCITTFaxDecode - check_rights(:allowCCITTFaxFilter) - when :JBIG2Decode - check_rights(:allowJBIG2Filter) - when :DCTDecode - check_rights(:allowDCTFilter) - when :JPXDecode - check_rights(:allowJPXFilter) - when :Crypt - check_rights(:allowCryptFilter) - end + if filters.is_a?(Origami::Array) + filters.each do |filter| + case filter.value + when :ASCIIHexDecode + check_rights(:allowASCIIHexFilter) + when :ASCII85Decode + check_rights(:allowASCII85Filter) + when :LZWDecode + check_rights(:allowLZWFilter) + when :FlateDecode + check_rights(:allowFlateDecode) + when :RunLengthDecode + check_rights(:allowRunLengthFilter) + when :CCITTFaxDecode + check_rights(:allowCCITTFaxFilter) + when :JBIG2Decode + check_rights(:allowJBIG2Filter) + when :DCTDecode + check_rights(:allowDCTFilter) + when :JPXDecode + check_rights(:allowJPXFilter) + when :Crypt + check_rights(:allowCryptFilter) + end + end + end end - end end - end - # - # TODO: Detect JS at opening in XFA (check event tag) - # Check image encoding in XFA ? - # Only allow valid signed documents ? - # Recursively scan attached files. - # On-the-fly injection of prerun JS code to hook vulnerable methods (dynamic exploit detection) ??? - # ... - # + # + # TODO: Detect JS at opening in XFA (check event tag) + # Check image encoding in XFA ? + # Only allow valid signed documents ? + # Recursively scan attached files. + # On-the-fly injection of prerun JS code to hook vulnerable methods (dynamic exploit detection) ??? + # ... + # - log("Document accepted by policy `#{@options[:policy]}'.", Console::Colors::GREEN) + log("Document accepted by policy `#{@options[:policy]}'.", :green) -rescue SystemExit -rescue Exception => e - log("An error occured during analysis : #{e.class} (#{e.message})") - reject("Analysis failure") +rescue + log("An error occured during analysis : #{$!.class} (#{$!.message})") + reject("Analysis failure") ensure - LOGGER.close + LOGGER.close end -