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.

screenshot

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:

firefox_oxOwOxsgh3

The decompiled water shader:

Code_Ypx9ji8gj8

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.

image-20230211205215594

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

image-20230211205250756

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

PaintDotNet_MC62x9rbmm

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.

VirtualBoxVM_uxtHJsRIoL

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.

VirtualBoxVM_Ur5Ga0KCO4

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.

Code_myMiIEz9NI

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)