Posts Google CTF 2020 - .NET
Post
Cancel

Google CTF 2020 - .NET

I will be describing in detail what I did and what I should have done.

What I Did

Saturday Evening

On Saturday evening, I started with this challenge. It’s a plain .NET executable, so I opened with dnSpy and started debugging.

i0

So, this is definitely a binary written in C++/CLI.
i1

Reading the docs for Harmony, it’s clear that the above code introduces a new prefix to the methods.

NUFFRA -> FYRKANTIG, GRONKULLA -> RIKTIG_OGLA, SPARSAM -> GRUNDTAL_NORRVIKEN, FLARDFULL -> DAGSTORP

where a -> b means a is executed when b is called, and then b is executed.

i2

Now i tried to step into <Module>.NativeGRUNDTAL_NORRVIKEN using dnSpy, but I couldn’t. Because it was pointing to unmanaged code.

i3

I didn’t see the RVA that dnSpy showed for this function. Following this RVA would landed me to the native code. What I did was, I opened the method body in hex editor and tried to locate the offset 0x2e90

i4

The offset 0x2e90 points to the following function

i5

Now the fun begins here. Following the function at 0x0404961, it jumps to the address 0x600004F. Now I got stuck, since I couldn’t resolve the address, I tried to make a logical guess what the function could be.

The loop that iterates n times, where n is the return value of 0x0404961. So, the possibility that the function can be strlen, since we know from dnSpy that the function at 0x403a90 takes a std::string*. Checking out the xrefs of 0x0404961 we get another one in 0x4039D0, which calls the target function 0x0404961 in the same way. So, 0x0404961 being strlen has a fair possibility.

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
.text:00402370 ToCustomBase64  proc near               ; CODE XREF: ToCustomBase64_+22↓p
.text:00402370
.text:00402370 arg_0           = byte ptr  8
.text:00402370
.text:00402370                 push    ebp
.text:00402371                 mov     ebp, esp
.text:00402373                 mov     dl, [ebp+arg_0]
.text:00402376                 lea     ecx, [edx-30h]
.text:00402379                 cmp     cl, 9
.text:0040237C                 ja      short loc_402383
.text:0040237E                 lea     eax, [edx-30h]
.text:00402381                 pop     ebp
.text:00402382                 retn
; ... snip ...
.text:0040239B loc_40239B:                             ; CODE XREF: ToCustomBase64+24↑j
.text:0040239B                 cmp     dl, 7Bh ; '{'
.text:0040239E                 jnz     short loc_4023A4
.text:004023A0                 mov     al, 3Eh ; '>'
.text:004023A2                 pop     ebp
.text:004023A3                 retn
.text:004023A4 ; ---------------------------------------------------------------------------
.text:004023A4
.text:004023A4 loc_4023A4:                             ; CODE XREF: ToCustomBase64+2E↑j
.text:004023A4                 or      eax, 0FFFFFFFFh
.text:004023A7                 mov     ecx, 3Fh ; '?'
.text:004023AC                 cmp     dl, 7Dh ; '}'
.text:004023AF                 cmovz   eax, ecx
.text:004023B2                 pop     ebp
.text:004023B3                 retn

Let’s analyze this block, then the function is clear.

1
2
3
4
5
.text:00402373                 mov     dl, [ebp+arg_0]
.text:00402376                 lea     ecx, [edx-30h]
.text:00402379                 cmp     cl, 9
.text:0040237C                 ja      short loc_402383
.text:0040237E                 lea     eax, [edx-30h]

So, this function takes a char, whose address is returned by 0x00404E02, so a strong possibility of 0x00404E02 is char& std::string::operator[].

In this piece of code, we have ecx ule 9 for 0x40237e to be executed. Where ule is unsigned less or equal. So, if \(ecx \lt 0\) then ecx has bit 31 set, implying \(ecx = 2^{31} + a\), so obviously ecx > 9. Therefore the only possibility is \(0 \le ecx \le 9\)

So, this block accepts digits and maps them to their integer equivalents.

ToCustomBase64 maps the input string '[0-9A-Za-z\{\}]+' to [0-63]. Digits are mapped to [0-9], uppercase letters are mapped to [10-35], lowercase letters to [36-61], { to 62 and } to 63

Next the transformed input is passed to FYRKANTIGImpl. Similarly, I traced the offset to 0x004044B0

i7

We have lea ecx, [ebp+src] before the call implying that the function sub_404F0C is a method of [ebp+src]. Something like this [ebp+src].sub_404F0C(byte_4081D0). So, it can be assumed that sub_404F0C is a std::string::string(char*) constructor. It then xor’s [ebp+dst] with [ebp+src]. Now I got stuck at sub_403F10

i8

Sunday Evening

I started debugging with IDA, it crashed. I moved onto x32dbg, but it crashed too… hope lost… Now I had to use WinDBG, my last option.

I started debugging with windbg. First I cleared the PEB.IsDebuggerPresent flag and then put a breakpoint on 0x00403F65. I traced the call to 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
        pszEnd      =   08h
        pszStart    =   0Ch
061e8218  push    ebp
061e8219  mov     ebp, esp
061e821b  push    edi
061e821c  push    esi
061e821d  push    ebx
061e821e  sub     esp, 20h
061e8221  mov     ebx, dword ptr fs:[0E20h]
061e8228  mov     dword ptr [ebp-28h], offset clr!InlinedCallFrame::vftable
061e822f  mov     dword ptr [ebp-2Ch], 0FFF5AFAEh
061e8236  mov     eax, dword ptr [ebx+0Ch]
061e8239  mov     dword ptr [ebp-24h], eax
061e823c  mov     dword ptr [ebp-14h], ebp
061e823f  mov     dword ptr [ebp-18h], 0
061e8246  lea     eax, [ebp-28h]
061e8249  mov     dword ptr [ebx+0Ch], eax
061e824c  mov     dword ptr [ebp-10h], ecx
061e824f  mov     eax, dword ptr [ebp+pszStart]
061e8252  cmp     eax, dword ptr [ebp+pszEnd]
061e8255  je      061e82c0

061e8257  mov     esi, dword ptr [ebp+pszStart]
061e825a  inc     esi
061e825b  cmp     esi, dword ptr [ebp+pszEnd]
061e825e  je      061e82c0

061e8260  mov     edi, esi
061e8262  sub     edi, dword ptr [ebp+pszStart]

061e8265  lea     eax, [edi+1]
061e8268  push    eax
061e8269  mov     ecx, dword ptr [ebp-10h]
061e826c  mov     dword ptr [ebp-20h], 1227420h
061e8273  mov     dword ptr [ebp-1Ch], esp
061e8276  mov     dword ptr [ebp-18h], 61E8287h
061e827d  mov     byte ptr [ebx+8], 0
061e8281  call    dword ptr ds:[122767Ch]           ; ucrtbase!rand
061e8287  mov     byte ptr [ebx+8], 1
061e828b  cmp     dword ptr [clr!g_TrapReturningThreads (755c2048)], 0
061e8292  je      061e829b
061e8294  push    eax
061e8295  call    clr!JIT_RareDisableHelper (74f3c2b0)
061e829a  pop     eax
061e829b  mov     dword ptr [ebp-18h], 0
061e82a2  mov     edx, eax
061e82a4  cmp     edx, edi
061e82a6  je      061e82b9

061e82a8  mov     eax, dword ptr [ebp+pszStart]
061e82ab  add     eax, edx
061e82ad  mov     edx, eax
061e82af  movsx   ecx, byte ptr [esi]
061e82b2  movsx   eax, byte ptr [edx]
061e82b5  mov     byte ptr [esi], al
061e82b7  mov     byte ptr [edx], cl

061e82b9  inc     esi
061e82ba  inc     edi
061e82bb  cmp     esi, dword ptr [ebp+pszEnd]
061e82be  jne     061e8265

061e82c0  mov     edi, dword ptr [ebp-24h]
061e82c3  mov     dword ptr [ebx+0Ch], edi
061e82c6  lea     esp, [ebp-0Ch]
061e82c9  pop     ebx
061e82ca  pop     esi
061e82cb  pop     edi
061e82cc  pop     ebp
061e82cd  ret     8

The call at 0x061e8281 lands right here:

i9

It points to rva 0x6bdc which is rand. So, what we have here is something like this

1
2
3
4
5
6
7
8
9
10
11
12
srand(0x23c);
// pEnd is actually pStart+len-2
for (int j = 1; j < 28; j++)
{
    int pos = rand() % (j+1);
    if (pos != j)
    {
        var tmp = ans[pos];
        ans[pos] = ans[j];
        ans[j] = tmp;
    }
}

i10

The functions 0x403b9c, 0x404df6 and 0x404e89 are useless since they don’t reference esi which is the transformed input param.

The next function of interest is 0x404eee. Tracing it with windbg leads to 0x403AE0. Now all functions are resolved but 0x0404C3E. Tracing it, we land into std::swap. So, 0x403ae0 can be represented as

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static List<uint> Shuffle2(List<uint> inp)
{
    var ans = new List<uint>(inp);
    for (int i = 0; i < inp.Count-1; i += 3)
    {
        if (i != 0x1c && i != 0x1b)
        {
            var t = ans[i];
            ans[i] = ans[i+1];
            ans[i+1] = t;
        }
    }
    return ans;
}

i11

Now GRUNDTAL_NORRVIKEN is called with the result of Shuffle2. We know that it has a prefix call to SPARSAM. So, SPARSAM will be called and then the control returns to GRUNDTAL_NORRVIKEN

i12

Note that it’s setting a ref param __result. According to Harmony’s docs, if a prefix assigns a value to __result, then the original function is skipped. So, SPARSAM replaces GRUNDTAL_NORRVIKEN. DAGSTORP is a plain xor, FLARDFULL does nothing. SMORBOLL computes a checksum and checks if the byte at index 28 matches the checksum. HEROISK calls VAXMYRA which ensures that all the bytes returned by DAGSTORP are unique and then validates a sequence of constraints.

Solver

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
#!/usr/bin/env python

import string
from z3 import *

def checksum(l):
    n = 16
    i = 0
    while i < 30:
        if i != 28:
            n += l[i]
            if (i % 2 == 0):
                n += l[i]
            if (i % 3 == 0):
                n += -2*l[i]
            if (i % 5 == 0):
                n += -3*l[i]
            if (i % 7 == 0):
                n += 4*l[i]
        i += 1
    return n & 63

def doCmp(a, b, c):
    return ULE(a-b, c)

def xse(n):
    return SignExt(24, Extract(7, 0, n))

def se(n):
    return n

def ze(n):
    return n

fuckingBase = string.digits + string.ascii_uppercase + string.ascii_lowercase + "{}"
invFuck = {}
for i, j in enumerate(fuckingBase):
    invFuck[j] = i

flag = [BitVec('i%d' % i, 8) for i in range(30)]
g = Solver()
for i in flag[4:-1]:
    g.add(0 <= i, i < 64)

for i, j in enumerate("CTF{"):
    g.add(flag[i] == invFuck[j])

g.add(flag[-1] == invFuck['}'])

f = []
t = [
    0x1F, 0x23, 0x3F, 0x3F, 0x1B, 0x07, 0x37, 0x21, 0x04, 0x33, 0x09, 0x3B, 0x39, 0x28, 0x30, 0x0C,
    0x0E, 0x2E, 0x3F, 0x25, 0x2A, 0x27, 0x3E, 0x0B, 0x27, 0x1C, 0x38, 0x31, 0x1E, 0x3D
]
# xor
for i in range(30):
    f.append(xse(flag[i])^t[i])

# shuffle done by rand() impl - 
f[0], f[1] = f[1], f[0]
f[1], f[2] = f[2], f[1]
f[0], f[3] = f[3], f[0]
f[2], f[4] = f[4], f[2]
f[3], f[5] = f[5], f[3]
f[3], f[6] = f[6], f[3]
f[0], f[8] = f[8], f[0]
f[6], f[9] = f[9], f[6]
f[7], f[10] = f[10], f[7]
f[7], f[11] = f[11], f[7]
f[0], f[12] = f[12], f[0]
f[3], f[13] = f[13], f[3]
f[10], f[14] = f[14], f[10]
f[6], f[15] = f[15], f[6]
f[15], f[16] = f[16], f[15]
f[3], f[17] = f[17], f[3]
f[5], f[18] = f[18], f[5]
f[12], f[19] = f[19], f[12]
f[14], f[20] = f[20], f[14]
f[3], f[21] = f[21], f[3]
f[5], f[22] = f[22], f[5]
f[8], f[23] = f[23], f[8]
f[17], f[24] = f[24], f[17]
f[23], f[25] = f[25], f[23]
f[22], f[26] = f[26], f[22]
f[10], f[27] = f[27], f[10]

for i in xrange(0, 29, 3):
    if i != 0x1c and i != 0x1b:
        f[i], f[i+1] = f[i+1], f[i]

g.add(checksum(f) == f[28])
g.add(Distinct(f))
g.add(f[1] == 25)
g.add(f[2] == 23)
g.add(f[9] == 9)
g.add(f[20] == 45)
g.add(f[26] == 7)
g.add(f[8] >= 15)
g.add(f[12] <= 4)
g.add(f[14] >= 48)
g.add(f[29] >= 1)

num = se(f[4])
num2 = se(f[3])
num3 = se(f[2])
num4 = se(f[1])
g.add(doCmp(se(f[0])+ ze(num4) + ze(num) + ze(num3) + ze(num2), 130, 10))
num4 = se(f[9])
num5 = se(f[8])
num6 = se(f[7])
num7 = se(f[6])
g.add(doCmp(se(f[5])+ ze(num7) + ze(num6) + ze(num5) + ze(num4), 140, 10))
num8 = se(f[14])
num9 = se(f[13])
num10 = se(f[12])
num11 = se(f[11])
g.add(doCmp(se(f[10]) + ze(num11) + ze(num10) + ze(num9) + ze(num8), 150, 10))
num12 = se(f[19])
num13 = se(f[18])
num14 = se(f[17])
num15 = se(f[16])
g.add(doCmp(se(f[15]) + ze(num15) + ze(num14) + ze(num13) + ze(num12), 160, 10))
num16 = se(f[24])
num17 = se(f[23])
num18 = se(f[22])
num19 = se(f[21])
g.add(doCmp(se(f[20]) + ze(num19) + ze(num18) + ze(num17) + ze(num16), 170, 10))
num20 = se(f[25])
num21 = se(f[20])
num22 = se(f[15])
num23 = se(f[10])
num24 = se(f[5])
g.add(doCmp(se(f[0] )+ ze(num24) + ze(num23) + ze(num22) + ze(num21) + ze(num20), 172, 6))
num25 = se(f[26])
num26 = se(f[21])
num27 = se(f[16])
num28 = se(f[11])
num29 = se(f[6])
g.add(doCmp(se(f[1] )+ ze(num29) + ze(num28) + ze(num27) + ze(num26) + ze(num25), 162, 6))
num30 = se(f[27])
num31 = se(f[22])
num32 = se(f[17])
num33 = se(f[12])
num34 = se(f[7])
g.add(doCmp(se(f[2] )+ ze(num34) + ze(num33) + ze(num32) + ze(num31) + ze(num30), 152, 6))
num35 = se(f[23])
num36 = se(f[18])
num37 = se(f[13])
num38 = se(f[8])
g.add(doCmp(se(f[3] )+ ze(num38) + ze(num37) + ze(num36) + ze(num35), 142, 6))
num39 = se(f[29])
num40 = se(f[24])
num41 = se(f[19])
num42 = se(f[14])
num43 = se(f[9])
g.add(doCmp(se(f[4] )+ ze(num43) + ze(num42) + ze(num41) + ze(num40) + ze(num39), 132, 6))
num44 = f[27] * 3
num45 = (f[7] + num44) * 3 - f[5] * 13
g.add(doCmp(num45, 57, 28))
num44 = f[20] * 5
num44 = (f[14] << 2) - num44
num45 = f[22] * 3 + num44
g.add(doCmp(num45, 12, 70))
num44 = f[18] * 2
num44 = (f[15] - num44) * 3
num46 = f[16] * 2
num46 = (f[14] + num46) * 2 + num44 - f[17] * 5
g.add(f[13] + num46 == 0)
num46 = f[6] * 2
g.add(f[5] == num46)
g.add(f[29] + f[7] == 59)
num47 = f[17] * 6
g.add(f[0] == num47)
num47 = f[9] * 4
g.add(f[8] == num47)
num47 = f[13] * 3
g.add((f[11] << 1) == num47)
g.add(f[13] + f[29] + f[11] + f[4] == f[19])
num48 = f[12] * 13
g.add(f[10] == num48)

# from:
# https://stackoverflow.com/questions/11867611/z3py-checking-all-solutions-for-equation
def get_models(s, M):
    result = []
    while len(result) < M and s.check() == sat:
        m = s.model()
        yield m
        result.append(m)
        block = []
        for d in m:
            if d.arity() > 0:
                raise Z3Exception('uninterpreted functions are not supported')
            c = d()
            if is_array(c) or c.sort().kind() == Z3_UNINTERPRETED_SORT:
                raise Z3Exception('arrays and uninterpreted sorts are not supported')
            block.append(c != m[d])
        s.add(Or(block))

def check(ans):
    l = ['WeirD', 'Weird', 'CppClr', 'Jit']
    return any(i in ans for i in l)

for m in get_models(g, 1337):
    fuck = []
    for j, i in enumerate(flag):
        fuck.append(m[i].as_long())
    # print " ".join("%02x" % i for i in fuck)
    x = "".join(fuckingBase[i] for i in fuck)
    if check(x):
        print x

Which prints the flag - CTF{CppClrIsWeirdButReallyFun}

My Mistakes…

  1. Failed to notice the RVA pointed out by dnSpy for unmanaged functions.
  2. Failed to recognize that the transition from unmanaged to managed code is done by using metadata tokens.

If I knew these, the challenge could be solved in one hour, 30 mins max.

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