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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  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. -- retry with another upstream until retransmits exceeds
  83. if retransmits > 0 then
  84. retransmits = retransmits - 1
  85. -- Select a different upstream!
  86. upstream = rule.upstreams:get_upstream_round_robin()
  87. addr = upstream:get_addr()
  88. lua_util.debugm(rule.name, task, '%s: error: %s; retry IP: %s; retries left: %s',
  89. rule.log_prefix, err, addr, retransmits)
  90. tcp.request({
  91. task = task,
  92. host = addr:to_string(),
  93. port = addr:get_port(),
  94. upstream = upstream,
  95. timeout = rule.timeout,
  96. shutdown = true,
  97. data = { protocol, content },
  98. callback = oletools_callback,
  99. })
  100. else
  101. rspamd_logger.errx(task, '%s: failed to scan, maximum retransmits ' ..
  102. 'exceed - err: %s', rule.log_prefix, error)
  103. common.yield_result(task, rule,
  104. 'failed to scan, maximum retransmits exceed - err: ' .. error,
  105. 0.0, 'fail', maybe_part)
  106. end
  107. end
  108. if err then
  109. oletools_requery(err)
  110. else
  111. json_response = json_response .. tostring(data)
  112. if not string.find(json_response, '\t\n\n\t') and #data == 8192 then
  113. lua_util.debugm(rule.name, task, '%s: no stop word: add_read - #json: %s / current packet: %s',
  114. rule.log_prefix, #json_response, #data)
  115. conn:add_read(oletools_callback)
  116. else
  117. local ucl_parser = ucl.parser()
  118. local ok, ucl_err = ucl_parser:parse_string(tostring(json_response))
  119. if not ok then
  120. rspamd_logger.errx(task, "%s: error parsing json response, retry: %s",
  121. rule.log_prefix, ucl_err)
  122. oletools_requery(ucl_err)
  123. return
  124. end
  125. local result = ucl_parser:get_object()
  126. local oletools_rc = {
  127. [0] = 'RETURN_OK',
  128. [1] = 'RETURN_WARNINGS',
  129. [2] = 'RETURN_WRONG_ARGS',
  130. [3] = 'RETURN_FILE_NOT_FOUND',
  131. [4] = 'RETURN_XGLOB_ERR',
  132. [5] = 'RETURN_OPEN_ERROR',
  133. [6] = 'RETURN_PARSE_ERROR',
  134. [7] = 'RETURN_SEVERAL_ERRS',
  135. [8] = 'RETURN_UNEXPECTED',
  136. [9] = 'RETURN_ENCRYPTED',
  137. }
  138. -- M=Macros, A=Auto-executable, S=Suspicious keywords, I=IOCs,
  139. -- H=Hex strings, B=Base64 strings, D=Dridex strings, V=VBA strings
  140. -- Keep sorted to avoid dragons
  141. local analysis_cat_table = {
  142. autoexec = '-',
  143. base64 = '-',
  144. dridex = '-',
  145. hex = '-',
  146. iocs = '-',
  147. macro_exist = '-',
  148. suspicious = '-',
  149. vba = '-'
  150. }
  151. local analysis_keyword_table = {}
  152. for _, v in ipairs(result) do
  153. if v.error ~= nil and v.type ~= 'error' then
  154. -- olefy, not oletools error
  155. rspamd_logger.errx(task, '%s: ERROR found: %s', rule.log_prefix,
  156. v.error)
  157. if v.error == 'File too small' then
  158. common.save_cache(task, digest, rule, 'OK', 1.0, maybe_part)
  159. common.log_clean(task, rule, 'File too small to be scanned for macros')
  160. return
  161. else
  162. oletools_requery(v.error)
  163. end
  164. elseif tostring(v.type) == "MetaInformation" and v.version ~= nil then
  165. -- if MetaInformation section - check and print script and version
  166. lua_util.debugm(N, task, '%s: version: %s %s', rule.log_prefix,
  167. tostring(v.script_name), tostring(v.version))
  168. elseif tostring(v.type) == "MetaInformation" and v.return_code ~= nil then
  169. -- if MetaInformation section - check return_code
  170. local oletools_rc_code = tonumber(v.return_code)
  171. if oletools_rc_code == 9 then
  172. rspamd_logger.warnx(task, '%s: File is encrypted.', rule.log_prefix)
  173. common.yield_result(task, rule,
  174. 'failed - err: ' .. oletools_rc[oletools_rc_code],
  175. 0.0, 'encrypted', maybe_part)
  176. common.save_cache(task, digest, rule, 'encrypted', 1.0, maybe_part)
  177. return
  178. elseif oletools_rc_code == 5 then
  179. rspamd_logger.warnx(task, '%s: olefy could not open the file - error: %s', rule.log_prefix,
  180. result[2]['message'])
  181. common.yield_result(task, rule,
  182. 'failed - err: ' .. oletools_rc[oletools_rc_code],
  183. 0.0, 'fail', maybe_part)
  184. return
  185. elseif oletools_rc_code > 6 then
  186. rspamd_logger.errx(task, '%s: MetaInfo section error code: %s',
  187. rule.log_prefix, oletools_rc[oletools_rc_code])
  188. rspamd_logger.errx(task, '%s: MetaInfo section message: %s',
  189. rule.log_prefix, result[2]['message'])
  190. common.yield_result(task, rule,
  191. 'failed - err: ' .. oletools_rc[oletools_rc_code],
  192. 0.0, 'fail', maybe_part)
  193. return
  194. elseif oletools_rc_code > 1 then
  195. rspamd_logger.errx(task, '%s: Error message: %s',
  196. rule.log_prefix, result[2]['message'])
  197. oletools_requery(oletools_rc[oletools_rc_code])
  198. end
  199. elseif tostring(v.type) == "error" then
  200. -- error section found - check message
  201. rspamd_logger.errx(task, '%s: Error section error code: %s',
  202. rule.log_prefix, v.error)
  203. rspamd_logger.errx(task, '%s: Error section message: %s',
  204. rule.log_prefix, v.message)
  205. --common.yield_result(task, rule, 'failed - err: ' .. v.error, 0.0, 'fail')
  206. elseif type(v.analysis) == 'table' and type(v.macros) == 'table' then
  207. -- analysis + macro found - evaluate response
  208. if type(v.analysis) == 'table' and #v.analysis == 0 and #v.macros == 0 then
  209. rspamd_logger.warnx(task, '%s: maybe unhandled python or oletools error', rule.log_prefix)
  210. oletools_requery('oletools unhandled error')
  211. elseif #v.macros > 0 then
  212. analysis_cat_table.macro_exist = 'M'
  213. lua_util.debugm(rule.name, task,
  214. '%s: filename: %s', rule.log_prefix, result[2]['file'])
  215. lua_util.debugm(rule.name, task,
  216. '%s: type: %s', rule.log_prefix, result[2]['type'])
  217. for _, m in ipairs(v.macros) do
  218. lua_util.debugm(rule.name, task, '%s: macros found - code: %s, ole_stream: %s, ' ..
  219. 'vba_filename: %s', rule.log_prefix, m.code, m.ole_stream, m.vba_filename)
  220. end
  221. for _, a in ipairs(v.analysis) do
  222. lua_util.debugm(rule.name, task, '%s: threat found - type: %s, keyword: %s, ' ..
  223. 'description: %s', rule.log_prefix, a.type, a.keyword, a.description)
  224. if a.type == 'AutoExec' then
  225. analysis_cat_table.autoexec = 'A'
  226. table.insert(analysis_keyword_table, a.keyword)
  227. elseif a.type == 'Suspicious' then
  228. if rule.extended == true or
  229. (a.keyword ~= 'Base64 Strings' and a.keyword ~= 'Hex Strings')
  230. then
  231. analysis_cat_table.suspicious = 'S'
  232. table.insert(analysis_keyword_table, a.keyword)
  233. end
  234. elseif a.type == 'IOC' then
  235. analysis_cat_table.iocs = 'I'
  236. elseif a.type == 'Hex strings' then
  237. analysis_cat_table.hex = 'H'
  238. elseif a.type == 'Base64 strings' then
  239. analysis_cat_table.base64 = 'B'
  240. elseif a.type == 'Dridex strings' then
  241. analysis_cat_table.dridex = 'D'
  242. elseif a.type == 'VBA strings' then
  243. analysis_cat_table.vba = 'V'
  244. end
  245. end
  246. end
  247. end
  248. end
  249. lua_util.debugm(N, task, '%s: analysis_keyword_table: %s', rule.log_prefix, analysis_keyword_table)
  250. lua_util.debugm(N, task, '%s: analysis_cat_table: %s', rule.log_prefix, analysis_cat_table)
  251. if rule.extended == false and analysis_cat_table.autoexec == 'A' and analysis_cat_table.suspicious == 'S' then
  252. -- use single string as virus name
  253. local threat = 'AutoExec + Suspicious (' .. table.concat(analysis_keyword_table, ',') .. ')'
  254. lua_util.debugm(rule.name, task, '%s: threat result: %s', rule.log_prefix, threat)
  255. common.yield_result(task, rule, threat, rule.default_score, nil, maybe_part)
  256. common.save_cache(task, digest, rule, threat, rule.default_score, maybe_part)
  257. elseif rule.extended == true and #analysis_keyword_table > 0 then
  258. -- report any flags (types) and any most keywords as individual virus name
  259. local analysis_cat_table_values_sorted = {}
  260. -- see https://github.com/rspamd/rspamd/commit/6bd3e2b9f49d1de3ab882aeca9c30bc7d526ac9d#commitcomment-40130493
  261. -- for details
  262. local analysis_cat_table_keys_sorted = lua_util.keys(analysis_cat_table)
  263. table.sort(analysis_cat_table_keys_sorted)
  264. for _, v in ipairs(analysis_cat_table_keys_sorted) do
  265. table.insert(analysis_cat_table_values_sorted, analysis_cat_table[v])
  266. end
  267. table.insert(analysis_keyword_table, 1, table.concat(analysis_cat_table_values_sorted))
  268. lua_util.debugm(rule.name, task, '%s: extended threat result: %s',
  269. rule.log_prefix, table.concat(analysis_keyword_table, ','))
  270. common.yield_result(task, rule, analysis_keyword_table,
  271. rule.default_score, nil, maybe_part)
  272. common.save_cache(task, digest, rule, analysis_keyword_table,
  273. rule.default_score, maybe_part)
  274. elseif analysis_cat_table.macro_exist == '-' and #analysis_keyword_table == 0 then
  275. common.save_cache(task, digest, rule, 'OK', 1.0, maybe_part)
  276. common.log_clean(task, rule, 'No macro found')
  277. else
  278. common.save_cache(task, digest, rule, 'OK', 1.0, maybe_part)
  279. common.log_clean(task, rule, 'Scanned Macro is OK')
  280. end
  281. end
  282. end
  283. end
  284. tcp.request({
  285. task = task,
  286. host = addr:to_string(),
  287. port = addr:get_port(),
  288. upstream = upstream,
  289. timeout = rule.timeout,
  290. shutdown = true,
  291. data = { protocol, content },
  292. callback = oletools_callback,
  293. })
  294. end
  295. if common.condition_check_and_continue(task, content, rule, digest,
  296. oletools_check_uncached, maybe_part) then
  297. return
  298. else
  299. oletools_check_uncached()
  300. end
  301. end
  302. return {
  303. type = { N, 'attachment scanner', 'hash', 'scanner' },
  304. description = 'oletools office macro scanner',
  305. configure = oletools_config,
  306. check = oletools_check,
  307. name = N
  308. }