Skip to content

Commit

Permalink
Merge pull request #57 from Yamato-Security/47-extract-out-powershell…
Browse files Browse the repository at this point in the history
…-script-block

feat: add `extract-scriptblocks` command
  • Loading branch information
YamatoSecurity authored Oct 26, 2023
2 parents c890427 + 62d4a51 commit f725b38
Show file tree
Hide file tree
Showing 5 changed files with 246 additions and 2 deletions.
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://github.com/PMunch/nancy/issues/4
# https://github.com/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"

0 comments on commit f725b38

Please sign in to comment.