You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

oletools.lua 14KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. --[[
  2. Copyright (c) 2022, Vsevolod Stakhov <vsevolod@rspamd.com>
  3. Copyright (c) 2018, Carsten Rosenberg <c.rosenberg@heinlein-support.de>
  4. Licensed under the Apache License, Version 2.0 (the "License");
  5. you may not use this file except in compliance with the License.
  6. You may obtain a copy of the License at
  7. http://www.apache.org/licenses/LICENSE-2.0
  8. Unless required by applicable law or agreed to in writing, software
  9. distributed under the License is distributed on an "AS IS" BASIS,
  10. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  11. See the License for the specific language governing permissions and
  12. limitations under the License.
  13. ]]--
  14. --[[[
  15. -- @module oletools
  16. -- This module contains oletools access functions.
  17. -- Olefy is needed: https://github.com/HeinleinSupport/olefy
  18. --]]
  19. local lua_util = require "lua_util"
  20. local tcp = require "rspamd_tcp"
  21. local upstream_list = require "rspamd_upstream_list"
  22. local rspamd_logger = require "rspamd_logger"
  23. local ucl = require "ucl"
  24. local common = require "lua_scanners/common"
  25. local N = 'oletools'
  26. local function oletools_config(opts)
  27. local oletools_conf = {
  28. name = N,
  29. scan_mime_parts = true,
  30. scan_text_mime = false,
  31. scan_image_mime = false,
  32. default_port = 10050,
  33. timeout = 15.0,
  34. log_clean = false,
  35. retransmits = 2,
  36. cache_expire = 86400, -- expire redis in 1d
  37. min_size = 500,
  38. symbol = "OLETOOLS",
  39. message = '${SCANNER}: Oletools threat message found: "${VIRUS}"',
  40. detection_category = "office macro",
  41. default_score = 1,
  42. action = false,
  43. extended = false,
  44. symbol_type = 'postfilter',
  45. dynamic_scan = true,
  46. }
  47. oletools_conf = lua_util.override_defaults(oletools_conf, opts)
  48. if not oletools_conf.prefix then
  49. oletools_conf.prefix = 'rs_' .. oletools_conf.name .. '_'
  50. end
  51. if not oletools_conf.log_prefix then
  52. if oletools_conf.name:lower() == oletools_conf.type:lower() then
  53. oletools_conf.log_prefix = oletools_conf.name
  54. else
  55. oletools_conf.log_prefix = oletools_conf.name .. ' (' .. oletools_conf.type .. ')'
  56. end
  57. end
  58. if not oletools_conf.servers then
  59. rspamd_logger.errx(rspamd_config, 'no servers defined')
  60. return nil
  61. end
  62. oletools_conf.upstreams = upstream_list.create(rspamd_config,
  63. oletools_conf.servers,
  64. oletools_conf.default_port)
  65. if oletools_conf.upstreams then
  66. lua_util.add_debug_alias('external_services', oletools_conf.name)
  67. return oletools_conf
  68. end
  69. rspamd_logger.errx(rspamd_config, 'cannot parse servers %s',
  70. oletools_conf.servers)
  71. return nil
  72. end
  73. local function oletools_check(task, content, digest, rule, maybe_part)
  74. local function oletools_check_uncached ()
  75. local upstream = rule.upstreams:get_upstream_round_robin()
  76. local addr = upstream:get_addr()
  77. local retransmits = rule.retransmits
  78. local protocol = 'OLEFY/1.0\nMethod: oletools\nRspamd-ID: ' .. task:get_uid() .. '\n\n'
  79. local json_response = ""
  80. local function oletools_callback(err, data, conn)
  81. local function oletools_requery(error)
  82. -- set current upstream to fail because an error occurred
  83. upstream:fail()
  84. -- retry with another upstream until retransmits exceeds
  85. if retransmits > 0 then
  86. retransmits = retransmits - 1
  87. -- Select a different upstream!
  88. upstream = rule.upstreams:get_upstream_round_robin()
  89. addr = upstream:get_addr()
  90. lua_util.debugm(rule.name, task, '%s: error: %s; retry IP: %s; retries left: %s',
  91. rule.log_prefix, err, addr, retransmits)
  92. tcp.request({
  93. task = task,
  94. host = addr:to_string(),
  95. port = addr:get_port(),
  96. timeout = rule.timeout,
  97. shutdown = true,
  98. data = { protocol, content },
  99. callback = oletools_callback,
  100. })
  101. else
  102. rspamd_logger.errx(task, '%s: failed to scan, maximum retransmits '..
  103. 'exceed - err: %s', rule.log_prefix, error)
  104. common.yield_result(task, rule,
  105. 'failed to scan, maximum retransmits exceed - err: ' .. error,
  106. 0.0, 'fail', maybe_part)
  107. end
  108. end
  109. if err then
  110. oletools_requery(err)
  111. else
  112. -- Parse the response
  113. if upstream then upstream:ok() end
  114. json_response = json_response .. tostring(data)
  115. if not string.find(json_response, '\t\n\n\t') and #data == 8192 then
  116. lua_util.debugm(rule.name, task, '%s: no stop word: add_read - #json: %s / current packet: %s',
  117. rule.log_prefix, #json_response, #data)
  118. conn:add_read(oletools_callback)
  119. else
  120. local ucl_parser = ucl.parser()
  121. local ok, ucl_err = ucl_parser:parse_string(tostring(json_response))
  122. if not ok then
  123. rspamd_logger.errx(task, "%s: error parsing json response, retry: %s",
  124. rule.log_prefix, ucl_err)
  125. oletools_requery(ucl_err)
  126. return
  127. end
  128. local result = ucl_parser:get_object()
  129. local oletools_rc = {
  130. [0] = 'RETURN_OK',
  131. [1] = 'RETURN_WARNINGS',
  132. [2] = 'RETURN_WRONG_ARGS',
  133. [3] = 'RETURN_FILE_NOT_FOUND',
  134. [4] = 'RETURN_XGLOB_ERR',
  135. [5] = 'RETURN_OPEN_ERROR',
  136. [6] = 'RETURN_PARSE_ERROR',
  137. [7] = 'RETURN_SEVERAL_ERRS',
  138. [8] = 'RETURN_UNEXPECTED',
  139. [9] = 'RETURN_ENCRYPTED',
  140. }
  141. -- M=Macros, A=Auto-executable, S=Suspicious keywords, I=IOCs,
  142. -- H=Hex strings, B=Base64 strings, D=Dridex strings, V=VBA strings
  143. -- Keep sorted to avoid dragons
  144. local analysis_cat_table = {
  145. autoexec = '-',
  146. base64 = '-',
  147. dridex = '-',
  148. hex = '-',
  149. iocs = '-',
  150. macro_exist = '-',
  151. suspicious = '-',
  152. vba = '-'
  153. }
  154. local analysis_keyword_table = {}
  155. for _, v in ipairs(result) do
  156. if v.error ~= nil and v.type ~= 'error' then
  157. -- olefy, not oletools error
  158. rspamd_logger.errx(task, '%s: ERROR found: %s', rule.log_prefix,
  159. v.error)
  160. if v.error == 'File too small' then
  161. common.save_cache(task, digest, rule, 'OK', 1.0, maybe_part)
  162. common.log_clean(task, rule, 'File too small to be scanned for macros')
  163. return
  164. else
  165. oletools_requery(v.error)
  166. end
  167. elseif tostring(v.type) == "MetaInformation" and v.version ~= nil then
  168. -- if MetaInformation section - check and print script and version
  169. lua_util.debugm(N, task, '%s: version: %s %s', rule.log_prefix,
  170. tostring(v.script_name), tostring(v.version))
  171. elseif tostring(v.type) == "MetaInformation" and v.return_code ~= nil then
  172. -- if MetaInformation section - check return_code
  173. local oletools_rc_code = tonumber(v.return_code)
  174. if oletools_rc_code == 9 then
  175. rspamd_logger.warnx(task, '%s: File is encrypted.', rule.log_prefix)
  176. common.yield_result(task, rule,
  177. 'failed - err: ' .. oletools_rc[oletools_rc_code],
  178. 0.0, 'encrypted', maybe_part)
  179. common.save_cache(task, digest, rule, 'encrypted', 1.0, maybe_part)
  180. return
  181. elseif oletools_rc_code == 5 then
  182. rspamd_logger.warnx(task, '%s: olefy could not open the file - error: %s', rule.log_prefix,
  183. result[2]['message'])
  184. common.yield_result(task, rule,
  185. 'failed - err: ' .. oletools_rc[oletools_rc_code],
  186. 0.0, 'fail', maybe_part)
  187. return
  188. elseif oletools_rc_code > 6 then
  189. rspamd_logger.errx(task, '%s: MetaInfo section error code: %s',
  190. rule.log_prefix, oletools_rc[oletools_rc_code])
  191. rspamd_logger.errx(task, '%s: MetaInfo section message: %s',
  192. rule.log_prefix, result[2]['message'])
  193. common.yield_result(task, rule,
  194. 'failed - err: ' .. oletools_rc[oletools_rc_code],
  195. 0.0, 'fail', maybe_part)
  196. return
  197. elseif oletools_rc_code > 1 then
  198. rspamd_logger.errx(task, '%s: Error message: %s',
  199. rule.log_prefix, result[2]['message'])
  200. oletools_requery(oletools_rc[oletools_rc_code])
  201. end
  202. elseif tostring(v.type) == "error" then
  203. -- error section found - check message
  204. rspamd_logger.errx(task, '%s: Error section error code: %s',
  205. rule.log_prefix, v.error)
  206. rspamd_logger.errx(task, '%s: Error section message: %s',
  207. rule.log_prefix, v.message)
  208. --common.yield_result(task, rule, 'failed - err: ' .. v.error, 0.0, 'fail')
  209. elseif type(v.analysis) == 'table' and type(v.macros) == 'table' then
  210. -- analysis + macro found - evaluate response
  211. if type(v.analysis) == 'table' and #v.analysis == 0 and #v.macros == 0 then
  212. rspamd_logger.warnx(task, '%s: maybe unhandled python or oletools error', rule.log_prefix)
  213. oletools_requery('oletools unhandled error')
  214. elseif #v.macros > 0 then
  215. analysis_cat_table.macro_exist = 'M'
  216. lua_util.debugm(rule.name, task,
  217. '%s: filename: %s', rule.log_prefix, result[2]['file'])
  218. lua_util.debugm(rule.name, task,
  219. '%s: type: %s', rule.log_prefix, result[2]['type'])
  220. for _,m in ipairs(v.macros) do
  221. lua_util.debugm(rule.name, task, '%s: macros found - code: %s, ole_stream: %s, '..
  222. 'vba_filename: %s', rule.log_prefix, m.code, m.ole_stream, m.vba_filename)
  223. end
  224. for _,a in ipairs(v.analysis) do
  225. lua_util.debugm(rule.name, task, '%s: threat found - type: %s, keyword: %s, '..
  226. 'description: %s', rule.log_prefix, a.type, a.keyword, a.description)
  227. if a.type == 'AutoExec' then
  228. analysis_cat_table.autoexec = 'A'
  229. table.insert(analysis_keyword_table, a.keyword)
  230. elseif a.type == 'Suspicious' then
  231. if rule.extended == true or
  232. (a.keyword ~= 'Base64 Strings' and a.keyword ~= 'Hex Strings')
  233. then
  234. analysis_cat_table.suspicious = 'S'
  235. table.insert(analysis_keyword_table, a.keyword)
  236. end
  237. elseif a.type == 'IOC' then
  238. analysis_cat_table.iocs = 'I'
  239. elseif a.type == 'Hex strings' then
  240. analysis_cat_table.hex = 'H'
  241. elseif a.type == 'Base64 strings' then
  242. analysis_cat_table.base64 = 'B'
  243. elseif a.type == 'Dridex strings' then
  244. analysis_cat_table.dridex = 'D'
  245. elseif a.type == 'VBA strings' then
  246. analysis_cat_table.vba = 'V'
  247. end
  248. end
  249. end
  250. end
  251. end
  252. lua_util.debugm(N, task, '%s: analysis_keyword_table: %s', rule.log_prefix, analysis_keyword_table)
  253. lua_util.debugm(N, task, '%s: analysis_cat_table: %s', rule.log_prefix, analysis_cat_table)
  254. if rule.extended == false and analysis_cat_table.autoexec == 'A' and analysis_cat_table.suspicious == 'S' then
  255. -- use single string as virus name
  256. local threat = 'AutoExec + Suspicious (' .. table.concat(analysis_keyword_table, ',') .. ')'
  257. lua_util.debugm(rule.name, task, '%s: threat result: %s', rule.log_prefix, threat)
  258. common.yield_result(task, rule, threat, rule.default_score, nil, maybe_part)
  259. common.save_cache(task, digest, rule, threat, rule.default_score, maybe_part)
  260. elseif rule.extended == true and #analysis_keyword_table > 0 then
  261. -- report any flags (types) and any most keywords as individual virus name
  262. local analysis_cat_table_values_sorted = {}
  263. -- see https://github.com/rspamd/rspamd/commit/6bd3e2b9f49d1de3ab882aeca9c30bc7d526ac9d#commitcomment-40130493
  264. -- for details
  265. local analysis_cat_table_keys_sorted = lua_util.keys(analysis_cat_table)
  266. table.sort(analysis_cat_table_keys_sorted)
  267. for _,v in ipairs(analysis_cat_table_keys_sorted) do
  268. table.insert(analysis_cat_table_values_sorted, analysis_cat_table[v])
  269. end
  270. table.insert(analysis_keyword_table, 1, table.concat(analysis_cat_table_values_sorted))
  271. lua_util.debugm(rule.name, task, '%s: extended threat result: %s',
  272. rule.log_prefix, table.concat(analysis_keyword_table, ','))
  273. common.yield_result(task, rule, analysis_keyword_table,
  274. rule.default_score, nil, maybe_part)
  275. common.save_cache(task, digest, rule, analysis_keyword_table,
  276. rule.default_score, maybe_part)
  277. elseif analysis_cat_table.macro_exist == '-' and #analysis_keyword_table == 0 then
  278. common.save_cache(task, digest, rule, 'OK', 1.0, maybe_part)
  279. common.log_clean(task, rule, 'No macro found')
  280. else
  281. common.save_cache(task, digest, rule, 'OK', 1.0, maybe_part)
  282. common.log_clean(task, rule, 'Scanned Macro is OK')
  283. end
  284. end
  285. end
  286. end
  287. tcp.request({
  288. task = task,
  289. host = addr:to_string(),
  290. port = addr:get_port(),
  291. timeout = rule.timeout,
  292. shutdown = true,
  293. data = { protocol, content },
  294. callback = oletools_callback,
  295. })
  296. end
  297. if common.condition_check_and_continue(task, content, rule, digest,
  298. oletools_check_uncached, maybe_part) then
  299. return
  300. else
  301. oletools_check_uncached()
  302. end
  303. end
  304. return {
  305. type = {N, 'attachment scanner', 'hash', 'scanner'},
  306. description = 'oletools office macro scanner',
  307. configure = oletools_config,
  308. check = oletools_check,
  309. name = N
  310. }