Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add extract-scriptblocks command #57

Merged
merged 12 commits into from
Oct 26, 2023
4 changes: 4 additions & 0 deletions CHANGELOG-Japanese.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## x.x.x [xxxx/xx/xx]

**新機能:**

- PowerShell EID 4104のScriptBlockログを元に戻す`extract-scriptblocks`コマンドを追加した。 (#47) (@fukusuket)

**改善:**

- TakajoがNim 2.0.0でコンパイルできるようになった。(#31) (@fukusuket)
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## x.x.x [xxxx/xx/xx]

**New Features:**

- New `extract-scriptblocks` command to reassemble PowerShell EID 4104 ScriptBlock logs. (#47) (@fukusuket)

**Enhancements:**

- Takajo now compiles with Nim 2.0.0. (#31) (@fukusuket)
Expand Down
16 changes: 15 additions & 1 deletion src/takajo.nim
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import algorithm
import cligen
import json
import nancy
import puppy
import re
import sets
Expand All @@ -9,13 +10,15 @@ import strformat
import strutils
import tables
import terminal
import termstyle
import times
import threadpool
import uri
import os
import std/enumerate
import suru
import takajopkg/general
include takajopkg/extractScriptblocks
include takajopkg/listDomains
include takajopkg/listIpAddresses
include takajopkg/listUndetectedEvtxFiles
Expand All @@ -34,6 +37,7 @@ include takajopkg/vtHashLookup
when isMainModule:
clCfg.version = "2.1.0-dev"
const examples = "Examples:\p"
const example_extract_scriptblocks = " extract-scriptblocks -t ../hayabusa/timeline.jsonl [--level low] -o scriptblock-logs\p"
const example_list_domains = " list-domains -t ../hayabusa/timeline.jsonl -o domains.txt\p"
const example_list_ip_addresses = " list-ip-addresses -t ../hayabusa/timeline.jsonl -o ipAddresses.txt\p"
const example_list_undetected_evtx = " list-undetected-evtx -t ../hayabusa/timeline.csv -e ../hayabusa-sample-evtx\p"
Expand All @@ -50,14 +54,24 @@ when isMainModule:
const example_vt_ip_lookup = " vt-ip-lookup -a <API-KEY> --ipList ipAddresses.txt -r 1000 -o results.csv --jsonOutput responses.json\p"

clCfg.useMulti = "Version: 2.1.0-dev\pUsage: takajo.exe <COMMAND>\p\pCommands:\p$subcmds\pCommand help: $command help <COMMAND>\p\p" &
examples & example_list_domains & example_list_hashes & example_list_ip_addresses & example_list_undetected_evtx & example_list_unused_rules &
examples & example_extract_scriptblocks & example_list_domains & example_list_hashes & example_list_ip_addresses & example_list_undetected_evtx & example_list_unused_rules &
example_split_csv_timeline & example_split_json_timeline & example_stack_logons & example_sysmon_process_tree &
example_timeline_logon & example_timeline_suspicious_processes &
example_vt_domain_lookup & example_vt_hash_lookup & example_vt_ip_lookup

if paramCount() == 0:
styledEcho(fgGreen, outputLogo())
dispatchMulti(
[
extractScriptblocks, cmdName = "extract-scriptblocks",
doc = "extract and reassemble PowerShell EID 4104 script block logs",
help = {
"level": "specify the minimum alert level",
"output": "output directory (default: scriptblock-logs)",
"quiet": "do not display the launch banner",
"timeline": "Hayabusa JSONL timeline (profile: any)",
}
],
[
listDomains, cmdName = "list-domains",
doc = "create a list of unique domains to be used with vt-domain-lookup",
Expand Down
220 changes: 220 additions & 0 deletions src/takajopkg/extractScriptblocks.nim
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
type
Script = object
firstTimestamp: string
computerName: string
scriptBlockId: string
scriptBlocks: OrderedSet[string]
levels: HashSet[string]
ruleTitles: HashSet[string]

proc outputScriptText(output: string, timestamp: string, computerName: string,
scriptObj: Script) =
var scriptText = ""
for text in scriptObj.scriptBlocks.items:
scriptText = scriptText & text.replace("\\r\\n", "\p").replace("\\\"", "\"").replace("\\t", "\t")
let date = timestamp.replace(":", "_").replace(" ", "_")
let fileName = output & "/" & computerName & "-" & date & "-" & scriptObj.scriptBlockId & ".txt"
var outputFile = open(filename, fmWrite)
outputFile.write(scriptText)
flushFile(outputFile)
close(outputFile)


proc calcMaxAlert(levels:HashSet):string =
if "crit" in levels:
return "crit"
if "high" in levels:
return "high"
if "med" in levels:
return "med"
if "low" in levels:
return "low"
if "info" in levels:
return "info"
return "N/A"

proc buildSummaryRecord(path: string, messageTotal: int,
scriptObj: Script): array[7, string] =
let ts = scriptObj.firstTimestamp
let cs = scriptObj.computerName
let id = scriptObj.scriptBlockId
let count = scriptObj.scriptBlocks.len
let records = $count & "/" & $messageTotal
let ruleTitles = fmt"{scriptObj.ruleTitles}".replace("{", "").replace("}", "")
let maxLevel = calcMaxAlert(scriptObj.levels)
return [ts, cs, id, path, records, maxLevel, ruleTitles]

proc colorWrite(color: ForegroundColor, ansiEscape: string, txt: string) =
# Remove ANSI escape sequences and use stdout.styledWrite instead
let replacedTxt = txt.replace(ansiEscape,"").replace(termClear,"")
if "│" in replacedTxt:
stdout.styledWrite(color, replacedTxt.replace("│ ",""))
stdout.write "│ "
else:
stdout.styledWrite(color, replacedTxt)

proc stdoutStyledWrite(txt: string) =
if txt.startsWith(termRed):
colorWrite(fgRed, termRed,txt)
elif txt.startsWith(termGreen):
colorWrite(fgGreen, termGreen, txt)
elif txt.startsWith(termYellow):
colorWrite(fgYellow, termYellow, txt)
elif txt.startsWith(termCyan):
colorWrite(fgCyan, termCyan, txt)
else:
stdout.write txt.replace(termClear,"")

proc echoTableSepsWithStyled(table: TerminalTable, maxSize = terminalWidth(), seps = defaultSeps) =
# This function equivalent to echoTableSeps without using ANSI escape to avoid the following issue.
# https:/PMunch/nancy/issues/4
# https:/PMunch/nancy/blob/9918716a563f64d740df6a02db42662781e94fc8/src/nancy.nim#L195C6-L195C19
let sizes = table.getColumnSizes(maxSize - 4, padding = 3)
printSeparator(top)
for k, entry in table.entries(sizes):
for _, row in entry():
stdout.write seps.vertical & " "
for i, cell in row():
stdoutStyledWrite cell & (if i != sizes.high: " " & seps.vertical & " " else: "")
stdout.write " " & seps.vertical & "\n"
if k != table.rows - 1:
printSeparator(center)
printSeparator(bottom)

proc extractScriptblocks(level: string = "low", output: string = "scriptblock-logs",
quiet: bool = false, timeline: string) =
let startTime = epochTime()
if not quiet:
styledEcho(fgGreen, outputLogo())

if not os.fileExists(timeline):
echo "The file '" & timeline & "' does not exist. Please specify a valid file path."
quit(1)

if not isJsonConvertible(timeline):
quit(1)

if level != "critical" and level != "high" and level != "medium" and level != "low" and level != "informational":
echo "You must specify a minimum level of critical, high, medium, low or informational. (default: low)"
echo ""
return

echo "Started the Extract ScriptBlock command."
echo "This command will extract PowerShell Script Block."
echo ""

echo "Counting total lines. Please wait."
let totalLines = countLinesInTimeline(timeline)
echo "Total lines: ", totalLines
echo ""
if level == "critical":
echo "Extracting PowerShell ScriptBlocks with an alert level of critical. Please wait."
else:
echo "Extracting PowerShell ScriptBlocks with a minimal alert level of " & level & ". Please wait."
echo ""

if not dirExists(output):
echo "The directory '" & output & "' does not exist so will be created."
createDir(output)
echo ""

var
bar: SuruBar = initSuruBar()
currentIndex = 0
stackedRecords = newTable[string, Script]()
summaryRecords = newOrderedTable[string, array[7, string]]()

bar[0].total = totalLines
bar.setup()

for line in lines(timeline):
inc currentIndex
inc bar
bar.update(1000000000) # refresh every second
let jsonLine = parseJson(line)
let eventLevel = jsonLine["Level"].getStr()
if jsonLine["EventID"].getInt(0) != 4104 or isMinLevel(eventLevel, level) == false:
continue

let
timestamp = jsonLine["Timestamp"].getStr()
computerName = jsonLine["Computer"].getStr()
ruleTitle = jsonLine["RuleTitle"].getStr()
scriptBlock = jsonLine["Details"]["ScriptBlock"].getStr()
scriptBlockId = jsonLine["ExtraFieldInfo"]["ScriptBlockId"].getStr()
messageNumber = jsonLine["ExtraFieldInfo"]["MessageNumber"].getInt()
messageTotal = jsonLine["ExtraFieldInfo"]["MessageTotal"].getInt()
var path = jsonLine["ExtraFieldInfo"].getOrDefault("Path").getStr()
if path == "":
path = "no-path"

if scriptBlockId in stackedRecords:
stackedRecords[scriptBlockId].levels.incl(eventLevel)
stackedRecords[scriptBlockId].ruleTitles.incl(ruleTitle)
stackedRecords[scriptBlockId].scriptBlocks.incl(scriptBlock)
else:
stackedRecords[scriptBlockId] = Script(firstTimestamp: timestamp,
computerName: computerName,
scriptBlockId: scriptBlockId,
scriptBlocks: toOrderedSet([scriptBlock]),
levels:toHashSet([eventLevel]), ruleTitles:toHashSet([ruleTitle]))

let scriptObj = stackedRecords[scriptBlockId]
if messageNumber == messageTotal:
if scriptBlockId in summaryRecords:
summaryRecords[scriptBlockId] = buildSummaryRecord(path, messageTotal, scriptObj)
# Already outputted
continue
outputScriptText(output, timestamp, computerName, scriptObj)
summaryRecords[scriptBlockId] = buildSummaryRecord(path, messageTotal, scriptObj)
elif currentIndex + 1 == totalLines:
outputScriptText(output, timestamp, computerName, scriptObj)
summaryRecords[scriptBlockId] = buildSummaryRecord(path, messageTotal, scriptObj)

bar.finish()
echo ""

if summaryRecords.len == 0:
echo "No malicious powershell script block were found. There are either no malicious powershell script block or you need to change the level."
else:
let summaryFile = output & "/" & "summary.csv"
let header = ["Creation Time", "Computer Name", "Script ID", "Script Name", "Records", "Level", "Alerts"]
var outputFile = open(summaryFile, fmWrite)
var table: TerminalTable
table.add header
for i, val in header:
if i < 6:
outputFile.write(escapeCsvField(val) & ",")
else:
outputFile.write(escapeCsvField(val) & "\p")
for v in summaryRecords.values:
if v[5] == "crit":
table.add red v[0], red v[1], red v[2], red v[3], red v[4], red v[5], red v[6]
elif v[5] == "high":
table.add yellow v[0], yellow v[1], yellow v[2], yellow v[3], yellow v[4], yellow v[5], yellow v[6]
elif v[5] == "med":
table.add cyan v[0], cyan v[1], cyan v[2], cyan v[3], cyan v[4], cyan v[5], cyan v[6]
elif v[5] == "low":
table.add green v[0], green v[1], green v[2], green v[3], green v[4], green v[5], green v[6]
else:
table.add v
for i, cell in v:
if i < 6:
outputFile.write(escapeCsvField(cell) & ",")
else:
outputFile.write(escapeCsvField(cell) & "\p")
let outputFileSize = getFileSize(outputFile)
outputFile.close()
table.echoTableSepsWithStyled(seps = boxSeps)
echo ""
echo "The extracted PowerShell ScriptBlock is saved in the directory: " & output
echo "Saved summary file: " & summaryFile & " (" & formatFileSize(outputFileSize) & ")"

let endTime = epochTime()
let elapsedTime = int(endTime - startTime)
let hours = elapsedTime div 3600
let minutes = (elapsedTime mod 3600) div 60
let seconds = elapsedTime mod 60
echo ""
echo "Elapsed time: ", $hours & " hours, " & $minutes & " minutes, " & $seconds & " seconds"
echo ""
4 changes: 3 additions & 1 deletion takajo.nimble
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@ bin = @["takajo"]
requires "nim >= 1.6.12"
requires "cligen >= 1.5"
requires "suru#f6f1e607c585b2bc2f71309996643f0555ff6349"
requires "puppy >= 2.1.0"
requires "puppy >= 2.1.0"
requires "termstyle"
requires "nancy"