# LACTF 2023 - pbr

##### Categories: re

##### 2023-02-12

##### by not_really

# rev/pbr

`pbr`

is a rust/vulkan challenge that shows `Ψβρ`

in 3D characters, along with some water that looks like it’s straight from the famous water shader on ShaderToy.

Decompiling the code is a disaster. IDA wins this time, but it still misses a few things here and there.

The first thing I did was look directly for the shader since the name of the challenge is `pbr`

and I assumed there were some shader shenanigans happening. I found the function called `lactf2023_pbr::PBR_SHADER::load`

and found the line loading the SPIR-V shader from the executable, which I then extracted.

I already had `spirv-dis`

on my path for whatever reason, so I ran the disassembler on the file. I spent a little bit of time working with the disassembly until I realized that `spirv-cross`

is a way better idea. To extract all four functions, I found the names with `spirv-cross shaders.dat --dump-resources`

(although I probably could’ve found these names in a hex editor), then extracted each one with `spirv-cross shaders.dat --entry <stage_name>`

.

```
Entry points:
pbr_frag (fragment)
pbr_vert (vertex)
sea_frag (fragment)
sea_vert (vertex)
```

pbr’s shaders are pretty small and nothing seems suspicious except for the fact that it uses an sbox uniform. Further inspection shows that it’s just used for a weird randomized alpha effect:

```
float _1137 = uv.y * 16.0;
uint _1144 = (_1137 > 4294967040.0) ? 4294967295u : ((!(_1137 >= 0.0)) ? 0u : uint(_1137));
if (_1144 < 16u)
{
float _1153 = uv.x * 32.0;
_out = vec4(fma(_6204.x, 0.800000011920928955078125, _5860.x),
fma(_6204.y, 0.800000011920928955078125, _5860.y),
fma(_6204.z, 0.800000011920928955078125, _5860.z),
float(
(sbox_3._m0[_1144].y >> int(((_1153 > 4294967040.0) ? 4294967295u : ((!(_1153 >= 0.0)) ? 0u : uint(_1153))) & 31u)) & 255));
break;
}
```

In the main code, we can see that a real sbox array exists and is passed to the shader at startup. So we know it truly is an sbox and there’s nothing weird going on here. The vert pbr shader doesn’t seem to be interesting either.

All that leaves is the water shader. Like I said before, the water looked eerily similar to the ShaderToy one, so I went over there to compare constants.

ShaderLab:

The decompiled water shader:

They both have the same very specific constants, so it’s a pretty good chance this is the same shader. (This isn’t a perfect check since other shaders could be using this hash function, but I double checked in many other places and it appears to be the same one, or at least based on it.)

The interesting part is that the code already seems to have separated the real shader from the flag checking code.

And at the very end, 1200 lines later, we get the actual flag checker.

The code is pretty long, so here’s an overview of the different parts.

### Part 1

Part 1 takes the value from `push._m0._m0._m3`

and checks if it’s equal to 0, 1, or 2 and selects a different matrix for each one. If it’s none of them, it picks a zero matrix, but at that point it skips the rest of the code. So we can assume 0, 1, and 2 are the only inputs here.

You might be asking what `push._m0._m0._m3`

is. The “push” part is referring to push constants, a way to send values to the shader (for all pixels and vertices). It’s a bit easier to look at this in RenderDoc so we can see the values while it’s running. Taking a snapshot and switching to the water shader’s frag program (click one of the blue rectangles on the top of the screen) shows the sbox and push uniforms. This is what the push one looks like.

Since we have the code to the original water shader(ish), we can guess that `_child1`

is the offset and `_child2`

is the time. `_child0`

is a matrix (probably not relevant), and `_child3`

is 0. We’re looking for `push._m0._m0._m3`

, which in RenderDoc would be `push._child0._child0._child3`

. Hmm, that’s not very useful. At this point, I tried spamming my keyboard, clicking all mouse buttons, resizing the window, minimizing the window, etc., but could not get the value to change from anything but 0. Weird, but okay.

### Part 2

Part 2 initializes a 69 element array, and loads `sbox_1`

into it. Looks like we’re loading the sbox again in this shader? But why only the first 69 elements?

### Part 3

Part 3’s pseudo code looks like this:

```
_m3 = _m3_from_part_1
mat = matrix_from_part_1
sbox_list = list_from_part_2
for i in range(69):
if sbox_list[i] == 0:
mat += 1
elif sbox_list[i] == 2:
mat = scramble_mat(mat)
elif sbox_list[i] == 4:
mat = ~mat
idx = (((_m3 + 1) * 70) - 2) - i
if idx < 210:
if sum(mat) != _138[idx]:
fail = True
```

Where `_138`

is a list of 210 values, which we’ll see in a bit. The first thing that should jump out to you is that `sbox_list`

is only checked to equal 0, 2, or 4. At first, I thought the switch case I translated this from might be invisibly bit masking the original value or something. But no, it really only seems to be checking 0, 2, or 4. Confused, I went back into RenderDoc to see if I could debug the value of `sbox_1`

. In the pbr shader, the sbox looks as expected, but in the water shader, sbox is empty.

They’ve played us like fools. This is not an sbox. This is a nothing box. Like `_m3`

, I tried my best to fill this array up with anything other than zero through normal keyboard/mouse inputs but failed miserably.

Right, so the `sbox`

may be useless, but what about the `_138`

array?

```
const uint _138[210] = uint[](
1583u, 1567u, 2513u, 2497u, 2497u, 2497u, 2481u, 1599u, 1599u, 1599u, 1583u, 2497u, 2497u, 2497u,
2497u, 2497u, 2481u, 2465u, 1615u, 1615u, 1599u, 1599u, 1599u, 1583u, 1583u, 1567u, 2513u, 2513u,
2513u, 1567u, 1567u, 1551u, 2529u, 1551u, 1551u, 1551u, 1551u, 1535u, 2545u, 2545u, 1535u, 1535u,
1535u, 1535u, 1535u, 2545u, 2529u, 2513u, 2497u, 2481u, 2481u, 1599u, 2481u, 2481u, 2465u, 1615u,
1599u, 2481u, 2465u, 2465u, 2465u, 2449u, 2449u, 1631u, 1631u, 2449u, 2433u, 2433u, 1647u, 2433u,
1584u, 1568u, 2512u, 2496u, 2496u, 2496u, 2480u, 1600u, 1600u, 1600u, 1584u, 2496u, 2496u, 2496u,
2496u, 2496u, 2480u, 2464u, 1616u, 1616u, 1600u, 1600u, 1600u, 1584u, 1584u, 1568u, 2512u, 2512u,
2512u, 1568u, 1568u, 1552u, 2528u, 1552u, 1552u, 1552u, 1552u, 1536u, 2544u, 2544u, 1536u, 1536u,
1536u, 1536u, 1536u, 2544u, 2528u, 2512u, 2496u, 2480u, 2480u, 1600u, 2480u, 2480u, 2464u, 1616u,
1600u, 2480u, 2464u, 2464u, 2464u, 2448u, 2448u, 1632u, 1632u, 2448u, 2432u, 2432u, 1648u, 2432u,
1515u, 1499u, 2581u, 2565u, 2565u, 2565u, 2549u, 1531u, 1531u, 1531u, 1515u, 2565u, 2565u, 2565u,
2565u, 2565u, 2549u, 2533u, 1547u, 1547u, 1531u, 1531u, 1531u, 1515u, 1515u, 1499u, 2581u, 2581u,
2581u, 1499u, 1499u, 1483u, 2597u, 1483u, 1483u, 1483u, 1483u, 1467u, 2613u, 2613u, 1467u, 1467u,
1467u, 1467u, 1467u, 2613u, 2597u, 2581u, 2565u, 2549u, 2549u, 1531u, 2549u, 2549u, 2533u, 1547u,
1531u, 2549u, 2533u, 2533u, 2533u, 2517u, 2517u, 1563u, 1563u, 2517u, 2501u, 2501u, 1579u, 2501u
);
```

I’ve split it up into three groups of 70 based on the code for calculating `idx`

in the pseudo code. Let’s assume that we have `_m3`

set to 0 and we’re in the first loop. In that case, `mat`

would be this (set in part 1):

```
mat_m3_0 = [
137, 154, 152, 202,
143, 149, 198, 156,
199, 133, 144, 135,
128, 134, 135, 134
]
```

And `idx`

would be `(((0 + 1) * 70) - 2) - 0 = 68`

. Weirdly, it checks the index from the very end to the very beginning.

If we sum every number in the matrix together right now, we really do get 2433, the last number in the first block. Looking good so far.

```
1583u, 1567u, 2513u, 2497u, 2497u, 2497u, 2481u, 1599u, 1599u, 1599u, 1583u, 2497u, 2497u, 2497u,
2497u, 2497u, 2481u, 2465u, 1615u, 1615u, 1599u, 1599u, 1599u, 1583u, 1583u, 1567u, 2513u, 2513u,
2513u, 1567u, 1567u, 1551u, 2529u, 1551u, 1551u, 1551u, 1551u, 1535u, 2545u, 2545u, 1535u, 1535u,
1535u, 1535u, 1535u, 2545u, 2529u, 2513u, 2497u, 2481u, 2481u, 1599u, 2481u, 2481u, 2465u, 1615u,
1599u, 2481u, 2465u, 2465u, 2465u, 2449u, 2449u, 1631u, 1631u, 2449u, 2433u, 2433u, 1647u, 2433u,
^
```

If we assume `sbox[0] == 0`

like we saw in RenderDoc, what happens first is that the matrix is incremented by 1 (each number in the matrix is incremented by 1). Starting at index 68 is 1647. However, if we add 16 to the first matrix’s sum, we get 2449, not 1647. So this would not pass.

If `sbox[0]`

was 4 though, it would invert all of the elements in the matrix. This would actually give us a sum of 1647. So it looks like `sbox`

may need to be our input.

I wrote up a quick script for `_m3 == 0`

to find a string that would be accepted by brute forcing all three options, 0, 2, and 4 for the correct sum. That string ended up being `2002402024224040420024444022220204222004202204242242044222204220422404`

. Cool, but not useful. How do we get a flag from this?

### Part 4

The code for this is short enough, so I’ll paste it here.

If the x frag coord (x coordinate pixel) is less than 16, set the pixel to a grayscale color of a value in the result matrix (or what I assume is the result matrix). So if the pixel was at x = 5, it would set the value to `rgb(mat[1][1],mat[1][1],mat[1][1],1.0)`

.

Is this a grayscale color corresponding to the ASCII value? I printed the end matrix instead of the input in my script. Here was the result:

```
rac1
lf5_
4vkt
{utu
```

There’s definitely a flag in there somewhere! It just needs to be unscrambled. I tried multiple things to see if my scramble script was wrong, but it didn’t seem to be. Nothing I changed with the code made it any better, so I decided I was going to do it by hand. The team and I spent a little bit trying to figure it out. We eventually came up with `lactf{ru5t_vu1k4n_`

. There were also two other matrices and lists if `_m3`

was 1 or 2. After passing those in, I got two more scrambled strings:

```
1_n3nsvmpa__pr_k
b3t3ry5d}_vt_a3r
```

Since I was pretty sure this was just a mess up with the scrambled matrices, they should be scrambled at the same indices. And sure enough, they are.

```
lactf{ru5t_vu1k4n_n_sp1rv_mak3_pr3tty_ba53d_r3v}
```

Not sure what the input was supposed to be, but this challenge was definitely… challenging.

```
path_a = [
1583, 1567, 2513, 2497, 2497, 2497, 2481, 1599, 1599, 1599, 1583, 2497, 2497, 2497,
2497, 2497, 2481, 2465, 1615, 1615, 1599, 1599, 1599, 1583, 1583, 1567, 2513, 2513,
2513, 1567, 1567, 1551, 2529, 1551, 1551, 1551, 1551, 1535, 2545, 2545, 1535, 1535,
1535, 1535, 1535, 2545, 2529, 2513, 2497, 2481, 2481, 1599, 2481, 2481, 2465, 1615,
1599, 2481, 2465, 2465, 2465, 2449, 2449, 1631, 1631, 2449, 2433, 2433, 1647, 2433
]
path_b = [
1584, 1568, 2512, 2496, 2496, 2496, 2480, 1600, 1600, 1600, 1584, 2496, 2496, 2496,
2496, 2496, 2480, 2464, 1616, 1616, 1600, 1600, 1600, 1584, 1584, 1568, 2512, 2512,
2512, 1568, 1568, 1552, 2528, 1552, 1552, 1552, 1552, 1536, 2544, 2544, 1536, 1536,
1536, 1536, 1536, 2544, 2528, 2512, 2496, 2480, 2480, 1600, 2480, 2480, 2464, 1616,
1600, 2480, 2464, 2464, 2464, 2448, 2448, 1632, 1632, 2448, 2432, 2432, 1648, 2432
]
path_c = [
1515, 1499, 2581, 2565, 2565, 2565, 2549, 1531, 1531, 1531, 1515, 2565, 2565, 2565,
2565, 2565, 2549, 2533, 1547, 1547, 1531, 1531, 1531, 1515, 1515, 1499, 2581, 2581,
2581, 1499, 1499, 1483, 2597, 1483, 1483, 1483, 1483, 1467, 2613, 2613, 1467, 1467,
1467, 1467, 1467, 2613, 2597, 2581, 2565, 2549, 2549, 1531, 2549, 2549, 2533, 1547,
1531, 2549, 2533, 2533, 2533, 2517, 2517, 1563, 1563, 2517, 2501, 2501, 1579, 2501
]
mat_a = [
137, 154, 152, 202,
143, 149, 198, 156,
199, 133, 144, 135,
128, 134, 135, 134
]
mat_b = [
202, 156, 141, 200,
141, 136, 133, 142,
139, 154, 156, 156,
139, 137, 156, 144
]
mat_c = [
153, 200, 135, 200,
137, 130, 198, 151,
126, 156, 133, 135,
156, 154, 200, 137
]
def add_one(m):
new_m = []
for i in range(16):
new_m.append((m[i] + 1) & 0xff)
return new_m
def scramble(m):
return [
m[10], m[13], m[6], m[12],
m[8], m[7], m[15], m[1],
m[4], m[11], m[9], m[2],
m[5], m[0], m[3], m[14]
]
# by guess
def unscramble(m):
return [
m[4], m[1], m[2], m[11],
m[5], m[12], m[0], m[13],
m[6], m[14], m[7], m[9],
m[15], m[3], m[10], m[8]
]
def invert(m):
new_m = []
for i in range(16):
new_m.append(m[i] ^ 0xff)
return new_m
def solve(m, path):
path = path[::-1]
for step in path:
zero_mat = add_one(m)
two_mat = scramble(m)
four_mat = invert(m)
zero_sum = sum(zero_mat)
two_sum = sum(two_mat)
four_sum = sum(four_mat)
if step == zero_sum:
m = zero_mat
elif step == two_sum:
#m = two_mat
m = m
elif step == four_sum:
m = four_mat
else:
print("something went wrong with the path")
exit(1)
m = unscramble(m)
return "".join(map(chr, m))
final_str = ""
final_str += solve(mat_a, path_a)
final_str += solve(mat_b, path_b)
final_str += solve(mat_c, path_c)
print(final_str)
```