Root-XMAS 2024 Day 11 - Padoru
# summary
A… shader reverse challenge? that's an original one! Two way to reverse them, statically, or dynamically… Let's try both!
# recon
We are provided a folder with a .exe and multiple files:
dir
fragment.spv # fragment shader
irrKlang.dll # irrlicht lib
msvcp140d.dll
padoru.exe # the binary to launch
padoru.obj # 3D model
padoru.ogg # the dreaded song file
padoru.pdb # nice, debug infos!
padoru_texture.dds # texture file
ucrtbased.dll
vcruntime140d.dll
vcruntime140_1d.dll
vertex.spv # vertex shader
Smells like a video game, or at least a 3D application! So let's launch it:
.\padoru.exe
Enter the correct christmas secret,
and try to unleash the hidden padoru colors:
aaa # random flag try
Shader program validation failed.
Failed to load SPIR-V shaders
Nothing else happens. Damn, the chall seem to be very difficult… Wait a minute, is it expected behavior?
After searching online for 30 minutes, reversing the executable to understand it was not part of the challenge, whining on the Discord, and then turning my brain on, I remembered I had a laptop, so I had to force Windows to use my shiny graphic card and not the stinky iGPU.
We relaunch the app and are greeted by Nero singing her beautiful christmas song . But we need to find the correct flag to unlock her "true colors".

We saw an error message about shaders, this made me curious, what's inside them?
strings fragment.spv
...
main
flagDetected
decChristmasLetter
TrueSecrets
encTrueChristmasSecret
finalChristmasKey
GuessedSecrets
guessedSecret
textureColor
...
strings vertex.spv
...
finalChristmasKey
Keys
initialChristmasKey
gl_VertexID
Wait, so our flag verifyier is written in shaders? Damn that's a new one for me. But these SPIR-V shaders are in an binary intermediate language, how to read them?
There are two methods to try and find the flag, dynamically and statically. I did a mix of both for my solve, but let's assume I knew what I was doing from start and let's try both methods individually!
disclaimer
Sorry if I said innacurate or just plain wrong stuff about graphics programming: I only remember basic stuff about this domain. I vaguely followed an OpenGL course 7 years ago, created a 3D crane and a toon shader, then proceeded to forget about this black magic.

# dynamic reversing solution
The reference for graphics app debugging (and reverse!) is RenderDoc , a tool allowing you to hook onto your app and its rendering libraries (OpenGL, Vulkan, DirectX), and capture everything that goes to the GPU: 3D models, textures, shaders, function calls and their parameters.
However when you are new to Graphics programming, it has a lot of options and it is easy to get underwhelmed and lost!

First, we need to capture a frame of our application to get some data: On the Launch Application window, set the path of the app. press Launch Button. We enter a random passphrase, and on the window that appears we have an overlay UI telling "F12 to capture". We do that, and on Renderdoc, on the padoru [PID XX] window we can select our frame.
Our goal is to decompile the shaders. So if we check the Pipeline State window, we can see all the rendering steps, including VS/Vertex Shader and FS/Fragment Shader .
Let's check FS first. There is a Shader panel with a View arrow button, let's press it… And we get a decompiled shader! Still it's some basic intermediate language conversion, we'll have to guess a bit what it does…

But wait a minute, at the top of the window, we can set a different Disassembly type. What if we try with GLSL (SPIR-V Cross (OpenGL Spir-V)) ?
#version 460
layout(binding = 4, std140) uniform TrueSecrets { int encTrueChristmasSecret[67]; } _28;
layout(binding = 1, std140) uniform GuessedSecrets { int guessedSecret[67]; } _45;
layout(binding = 2) uniform sampler2D sampler;
layout(location = 4) flat in int finalChristmasKey;
layout(location = 3) in vec2 UV;
layout(location = 0) out vec4 color;
void main()
{
bool flagDetected = true;
for (int i = 0; i < 67; i++)
{
int decChristmasLetter = _28.encTrueChristmasSecret[i] ^ ((i + finalChristmasKey) % 25);
if (decChristmasLetter != _45.guessedSecret[i])
{
flagDetected = false;
break;
}
}
vec4 textureColor = texture(sampler, UV);
if (flagDetected)
{
textureColor = vec4(1.0 - textureColor.x, 1.0 - textureColor.y, 1.0 - textureColor.z, textureColor.w);
}
color = textureColor;
}
Damn, that is way, way clearer! The code is now looking very simple, let's summarize it:
We have two uniform
parameters ("constant" values) passed to the shader, guessedSecret
and encTrueChristmasSecret
.
There is also an in
parameter, finalChristmasKey
which comes from previous shaders output.
At each render frame, the shader will compare each character of guessedSecret
with the ones of encTrueChristmasSecret
xored with the finalChristmasKey
( plus the character index, and modulo 25).
If the correct flag is passed, the rest of the shader executes, inverting the colors of the character's texture.
coming back to the RenderDoc Pipeline State window, we can see the uniform values passed to the shader, most interestingly the TrueSecrets
/ encTrueChristmasSecret
one, by clicking on it's Go arrow. It is a 67 "encoded" character string, we can even save it as a CSV!

Armed with all this information, we can write a simple python script to un-xor the flag:
import csv
def solve(finalChristmasKey: int) # don't have the key yet
res = ""
with open('enc_bytes.csv') as csvfile:
spamreader = csv.reader(csvfile, delimiter=',')
next(spamreader); next(spamreader) # header lines
for i, row in enumerate(spamreader):
c = row[1]
res += chr(int(c) ^ ((i + finalChristmasKey) % 25))
print(res)
However we don't have the flat in int finalChristmasKey
parameter showed on the RenderDoc Pipeline State window.
Still we can solve it by bruteforce , because the finalChristmasKey
is only a single int, and is modulo 25, so there are really only 25 combinations.
for i in range(25):
solve(i)
...
]UzK5TI2S<^P1U0\X/^H5]2\O?GX3TXO2^[RVJ6L2I+S7^P1\Q;\1QTXQ7E?SV & "|
RM{H4SH1R3_S0R1_Y0_K4Z3_N0_Y0U_N1_TSUK1M1H4R4_W0_P4D0RU_P4D0RU!!!!}
JLxI3RK0]2\R7S2^F1\J3[0^A(^Z1R^M0PUPTL0N0W5Q5XV3^_,E3SR^S5K1QT& " b
Well done, Well done… HOWEVER! this is not enough, we need to go deeper and solve this rightfully, find the true finalChristmasKey
!
We now from our strings
command that finalChristmasKey
is somewhere in the Vertex Shader. After all it's logical, the Vertex shader is executed before the Fragment one, and out
data from the first becomes in
data for the other!
So let's check the code on the VS/Vertex Shader tab:
layout(binding = 0, std140) uniform Matrices { mat4 MVP; } _19;
layout(binding = 3, std140) uniform Keys { int initialChristmasKey; } _45;
layout(location = 0) in vec3 vertexPosition;
layout(location = 3) out vec2 UV;
layout(location = 1) in vec2 vertexUV;
layout(location = 4) out int finalChristmasKey;
void main()
{
gl_Position = _19.MVP * vec4(vertexPosition, 1.0);
UV = vertexUV;
finalChristmasKey = (_45.initialChristmasKey + 2512) % 2024;
}
And we get the Keys_var
/ initialChristmasKey
in Renderdoc like for our Vertex Shader:

calculating finalChristmasKey
is the trivial:
initialChristmasKey = 25122024
finalChristmasKey = (initialChristmasKey + 2512) % 2024
624
trivia
initialChristmasKey % 25 == finalChristmasKey % 25
, so you can totally find the initial variable and mistake it for the final one , skipping a step unknowingly!
But! it's not good enough! we don't want to do any calculation by hand, what if this shader code was non-trivial? I'm certain finalChristmasKey
is wandering somewhere in the RenderDoc interface. But where? someone already asked the question, that was unanswered… until now!
The output of the Vertex shader is available in the Mesh Viewer window.
However , Renderdoc captures all the rendering steps, and the variable we are interested in is only available at the glDrawArrays
step, that we have to select on the Event Browser window.
Add to this to the fact that the finalChristmasKey
variable was hidden on my interface because the tab content was cropped and I had to scroll right to see it, it was really easy to miss!

we can finally call our python solve
method with the real finalChristmasKey
:
solve(624)
'RM{H4SH1R3_S0R1_Y0_K4Z3_N0_Y0U_N1_TSUK1M1H4R4_W0_P4D0RU_P4D0RU!!!!}'
If we use this flag on the challenge, we unlock an inverted color shader!

# static reversing solution
But what if we are on a 2006 eeePC running Linux only, could we solve the chall without launching the executable?
We will not cover everything already discussed on the dynamic analysis section, like the shader code analysis. Let's focus on decompiling the shader and finding it's parameters in the executable binary.
First, for decompiling the shaders, we see that RenderDoc used SPIR-V Cross . It's actually a tool created by the Khronos group (the consortium managing OpenGL and Vulkan) to decompile SPIR-V shaders to multiple other languages. And it's available as a handful standalone!
spirv-cross fragment.spv
...
void main()
{
bool flagDetected = true;
for (int i = 0; i < 67; i++)
...
Now Let's find the parameters sent to our shaders by padoru.exe . Thankfully we have the .pdb debug file, so Ghidra loads the executable with some variable and methods names !
We get a pretty long main
that does lot of things: Get the flag input, initialize the window with glfw , load the 3D model , the dreaded sound file with IrrKlang, but most importantly loads the shaders and set their parameters! And we see some suspicious code patterns:
if (local_b24[0] == 0xffffffff) {
pbVar4 = std::operator<<<std::char_traits<char>_>
((basic_ostream<> *)cerr_exref,"Failed to locate key uniform");
std::basic_ostream<>::operator<<((basic_ostream<> *)pbVar4,std::endl<>);
local_44 = 0xffffffff;
std::vector<>::~vector<>(&local_bc0);
...
}
else {
(*__glewBufferSubData)(0x8a11,0,0x10,&christmasKey);
(*__glewGenBuffers)(1,local_b04);
...
We see a christmasKey
variable! And not far after we also see encTrueChristmasSecret
for (local_668[269] = 0; local_668[269] < 0x43;
local_668[269] = local_668[269] + 1) {
local_668[local_668[269] << 2] = encTrueChristmasSecret[local_668[269]];
local_668[local_668[269] * 4 + 1] = 0;
...
And Hooray, both variables are constants! We only need to click on them, and right click to copy our two variables. We can then solve the chall statically!

Damn, static analysis was more straightforward than the dynamic one!
Previous day | Day 10 - Route-Mi ShopDay 10 - Route-Mi Shop |
---|---|
Next day | Day 12 - The Naughty SnowmanDay 12 - The Naughty Snowman |