mirror of
https://github.com/syoyo/tinygltf.git
synced 2026-06-08 11:13:50 +00:00
Harden v3 C parser against untrusted glTF input
Threat model: parser is intended for server-side processing of attacker- supplied glTF/GLB. Two adversarial review rounds plus a 1-hour libFuzzer run (4 workers, ASan+UBSan, ~420M execs total, zero new artifacts) drove this set of fixes. Concrete PoCs in tests/v3/security/ confirmed each issue was exploitable on the prior code. Path traversal (CRITICAL): tg3__load_external_file concatenated base_dir with the JSON-supplied URI verbatim. A glTF with "uri":"../../../tmp/secret" successfully loaded the file from outside base_dir (verified by FNV64 match). New tg3__uri_is_safe rejects empty, NUL, leading / or \\, Windows drive prefixes, and any '..' segment. Path-buffer length checks switched to saturating subtraction so 32-bit size_t cannot wrap. Sign-coercion in byteStride: int32_t -1 was cast directly to uint32_t, producing 0xFFFFFFFF and propagating into downstream count*stride math. Restrict to glTF spec range: 0 (tightly packed) or [4, 252]. Index validation: parsed int32 index fields (accessor.bufferView, primitive.indices/material/attributes, node.mesh/skin/camera/light, scene.nodes[], skin.joints[], animation channel/sampler refs, MSFT_lod ids, KHR_audio emitter/source refs, etc.) were stored unchecked. New tg3__validate_indices walks every index field and returns TG3_ERR_INVALID_INDEX on out-of-range. Gated by tg3_parse_options.validate_indices, defaulting to 1. Use-after-free on parse failure (PRE-EXISTING, surfaced by ASan during fix verification): tg3_parse and tg3_parse_glb destroyed model->arena_ on error paths, but error messages on the user-facing tg3_error_stack were arena-allocated. Any caller reading errors.entries[i].message after parse failure read freed memory. tg3_model_free is now sole arena owner; arena lives across error paths so messages stay valid until the caller frees the model. Other fixes: - tg3_parse_glb: hoist tg3__model_init before header parse so callers can safely tg3_model_free on header failure. - tg3__parse_primitive morph targets: when arena alloc returns NULL, pair with target_counts[ti]=0 so validators do not deref. - Defensive 'if (!tarr) continue' in the morph-target validator loop. - New Security Considerations block in tiny_gltf_v3.h documents the threat model, default-on validation, fs-callback contract, and error message lifetime. Verification: 13 internal tests in tester_v3_c (incl. 7 new security regressions covering path traversal absolute and relative, fs-callback no-call assertion, byteStride wrap, OOB index, opt-in raw mode, ext fields, and arena-message lifetime), 134/134 Khronos sample models match v1 ground truth digest, 1-hour ASan+UBSan fuzz on the final code clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -423,6 +423,232 @@ static int check_huge_integer_field_rejected(void) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* ===== Security regression tests ============================================ */
|
||||
|
||||
/* fs read_file callback that records calls into *(int *)user_data and never
|
||||
* succeeds — used to verify path-traversal URIs never reach the filesystem. */
|
||||
static int32_t recording_read_file(uint8_t **out_data, uint64_t *out_size,
|
||||
const char *path, uint32_t path_len,
|
||||
void *user_data) {
|
||||
(void)out_data; (void)out_size; (void)path; (void)path_len;
|
||||
if (user_data) *(int *)user_data += 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int check_path_traversal_rejected(void) {
|
||||
static const uint8_t json[] =
|
||||
"{\"asset\":{\"version\":\"2.0\"},"
|
||||
"\"buffers\":[{\"uri\":\"../../etc/passwd\",\"byteLength\":4}]}";
|
||||
tg3_model model;
|
||||
tg3_error_stack errors;
|
||||
tg3_parse_options opts;
|
||||
tg3_error_code err;
|
||||
int fs_calls = 0;
|
||||
uint32_t i;
|
||||
int saw = 0;
|
||||
|
||||
tg3_error_stack_init(&errors);
|
||||
tg3_parse_options_init(&opts);
|
||||
opts.fs.read_file = recording_read_file;
|
||||
opts.fs.user_data = &fs_calls;
|
||||
err = tg3_parse(&model, &errors, json, (uint64_t)(sizeof(json) - 1),
|
||||
"/some/base", 10, &opts);
|
||||
if (err == TG3_OK) {
|
||||
fprintf(stderr, "path traversal NOT rejected\n");
|
||||
goto fail;
|
||||
}
|
||||
if (fs_calls != 0) {
|
||||
fprintf(stderr, "fs.read_file called %d times for traversal URI\n", fs_calls);
|
||||
goto fail;
|
||||
}
|
||||
for (i = 0; i < errors.count; ++i) {
|
||||
if (errors.entries[i].code == TG3_ERR_INVALID_VALUE) saw = 1;
|
||||
}
|
||||
if (!saw) {
|
||||
fprintf(stderr, "expected TG3_ERR_INVALID_VALUE on traversal\n");
|
||||
goto fail;
|
||||
}
|
||||
tg3_model_free(&model);
|
||||
tg3_error_stack_free(&errors);
|
||||
return 1;
|
||||
fail:
|
||||
tg3_model_free(&model);
|
||||
tg3_error_stack_free(&errors);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int check_absolute_uri_rejected(void) {
|
||||
static const uint8_t json[] =
|
||||
"{\"asset\":{\"version\":\"2.0\"},"
|
||||
"\"buffers\":[{\"uri\":\"/etc/passwd\",\"byteLength\":4}]}";
|
||||
tg3_model model;
|
||||
tg3_error_stack errors;
|
||||
tg3_parse_options opts;
|
||||
tg3_error_code err;
|
||||
int fs_calls = 0;
|
||||
|
||||
tg3_error_stack_init(&errors);
|
||||
tg3_parse_options_init(&opts);
|
||||
opts.fs.read_file = recording_read_file;
|
||||
opts.fs.user_data = &fs_calls;
|
||||
err = tg3_parse(&model, &errors, json, (uint64_t)(sizeof(json) - 1),
|
||||
"/base", 5, &opts);
|
||||
if (err == TG3_OK || fs_calls != 0) {
|
||||
fprintf(stderr, "absolute URI not rejected (rc=%d, fs_calls=%d)\n",
|
||||
(int)err, fs_calls);
|
||||
tg3_model_free(&model);
|
||||
tg3_error_stack_free(&errors);
|
||||
return 0;
|
||||
}
|
||||
tg3_model_free(&model);
|
||||
tg3_error_stack_free(&errors);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int check_negative_byte_stride_rejected(void) {
|
||||
static const uint8_t json[] =
|
||||
"{\"asset\":{\"version\":\"2.0\"},"
|
||||
"\"buffers\":[{\"byteLength\":4}],"
|
||||
"\"bufferViews\":[{\"buffer\":0,\"byteLength\":4,\"byteStride\":-1}]}";
|
||||
tg3_model model;
|
||||
tg3_error_stack errors;
|
||||
tg3_parse_options opts;
|
||||
tg3_error_code err;
|
||||
|
||||
tg3_error_stack_init(&errors);
|
||||
tg3_parse_options_init(&opts);
|
||||
err = tg3_parse(&model, &errors, json, (uint64_t)(sizeof(json) - 1), "", 0, &opts);
|
||||
if (err == TG3_OK) {
|
||||
fprintf(stderr, "negative byteStride NOT rejected\n");
|
||||
tg3_model_free(&model);
|
||||
tg3_error_stack_free(&errors);
|
||||
return 0;
|
||||
}
|
||||
tg3_model_free(&model);
|
||||
tg3_error_stack_free(&errors);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int check_oob_index_rejected(void) {
|
||||
static const uint8_t json[] =
|
||||
"{\"asset\":{\"version\":\"2.0\"},"
|
||||
"\"buffers\":[{\"byteLength\":4}],"
|
||||
"\"bufferViews\":[{\"buffer\":0,\"byteLength\":4}],"
|
||||
"\"accessors\":[{\"bufferView\":1000000,\"componentType\":5121,"
|
||||
"\"count\":1,\"type\":\"SCALAR\"}]}";
|
||||
tg3_model model;
|
||||
tg3_error_stack errors;
|
||||
tg3_parse_options opts;
|
||||
tg3_error_code err;
|
||||
|
||||
tg3_error_stack_init(&errors);
|
||||
tg3_parse_options_init(&opts);
|
||||
err = tg3_parse(&model, &errors, json, (uint64_t)(sizeof(json) - 1), "", 0, &opts);
|
||||
if (err != TG3_ERR_INVALID_INDEX) {
|
||||
fprintf(stderr, "OOB index expected TG3_ERR_INVALID_INDEX, got %d\n", (int)err);
|
||||
tg3_model_free(&model);
|
||||
tg3_error_stack_free(&errors);
|
||||
return 0;
|
||||
}
|
||||
tg3_model_free(&model);
|
||||
tg3_error_stack_free(&errors);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int check_oob_index_opt_in(void) {
|
||||
/* When validate_indices=0, parse should accept the same out-of-range index. */
|
||||
static const uint8_t json[] =
|
||||
"{\"asset\":{\"version\":\"2.0\"},"
|
||||
"\"buffers\":[{\"byteLength\":4}],"
|
||||
"\"bufferViews\":[{\"buffer\":0,\"byteLength\":4}],"
|
||||
"\"accessors\":[{\"bufferView\":1000000,\"componentType\":5121,"
|
||||
"\"count\":1,\"type\":\"SCALAR\"}]}";
|
||||
tg3_model model;
|
||||
tg3_error_stack errors;
|
||||
tg3_parse_options opts;
|
||||
tg3_error_code err;
|
||||
|
||||
tg3_error_stack_init(&errors);
|
||||
tg3_parse_options_init(&opts);
|
||||
opts.validate_indices = 0;
|
||||
err = tg3_parse(&model, &errors, json, (uint64_t)(sizeof(json) - 1), "", 0, &opts);
|
||||
if (err != TG3_OK) {
|
||||
fprintf(stderr, "validate_indices=0 should accept OOB index, got %d\n", (int)err);
|
||||
tg3_model_free(&model);
|
||||
tg3_error_stack_free(&errors);
|
||||
return 0;
|
||||
}
|
||||
if (model.accessors_count != 1 || model.accessors[0].buffer_view != 1000000) {
|
||||
fprintf(stderr, "OOB index not preserved when validation off\n");
|
||||
tg3_model_free(&model);
|
||||
tg3_error_stack_free(&errors);
|
||||
return 0;
|
||||
}
|
||||
tg3_model_free(&model);
|
||||
tg3_error_stack_free(&errors);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int check_extension_index_oob_rejected(void) {
|
||||
/* MSFT_lod and KHR_audio index fields must be validated when
|
||||
* validate_indices=1, otherwise downstream consumers can read OOB. */
|
||||
static const uint8_t json[] =
|
||||
"{\"asset\":{\"version\":\"2.0\"},"
|
||||
"\"nodes\":[{\"extensions\":{\"MSFT_lod\":{\"ids\":[99999]}}}]}";
|
||||
tg3_model model;
|
||||
tg3_error_stack errors;
|
||||
tg3_parse_options opts;
|
||||
tg3_error_code err;
|
||||
|
||||
tg3_error_stack_init(&errors);
|
||||
tg3_parse_options_init(&opts);
|
||||
err = tg3_parse(&model, &errors, json, (uint64_t)(sizeof(json) - 1), "", 0, &opts);
|
||||
if (err != TG3_ERR_INVALID_INDEX) {
|
||||
fprintf(stderr, "MSFT_lod OOB index expected TG3_ERR_INVALID_INDEX, got %d\n", (int)err);
|
||||
tg3_model_free(&model);
|
||||
tg3_error_stack_free(&errors);
|
||||
return 0;
|
||||
}
|
||||
tg3_model_free(&model);
|
||||
tg3_error_stack_free(&errors);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int check_error_messages_survive_parse_failure(void) {
|
||||
/* Regression: parse failure must not invalidate arena-allocated error
|
||||
* message strings on the user's tg3_error_stack before model_free. */
|
||||
static const uint8_t json[] =
|
||||
"{\"asset\":{\"version\":\"2.0\"},"
|
||||
"\"buffers\":[{\"byteLength\":4}],"
|
||||
"\"bufferViews\":[{\"buffer\":0,\"byteLength\":4,\"byteStride\":-1}]}";
|
||||
tg3_model model;
|
||||
tg3_error_stack errors;
|
||||
tg3_parse_options opts;
|
||||
tg3_error_code err;
|
||||
uint32_t i;
|
||||
int seen_stride_msg = 0;
|
||||
|
||||
tg3_error_stack_init(&errors);
|
||||
tg3_parse_options_init(&opts);
|
||||
err = tg3_parse(&model, &errors, json, (uint64_t)(sizeof(json) - 1), "", 0, &opts);
|
||||
if (err == TG3_OK) goto fail;
|
||||
for (i = 0; i < errors.count; ++i) {
|
||||
const char *m = errors.entries[i].message;
|
||||
if (m && strstr(m, "byteStride")) seen_stride_msg = 1;
|
||||
}
|
||||
if (!seen_stride_msg) {
|
||||
fprintf(stderr, "byteStride error message missing or unreadable after parse\n");
|
||||
goto fail;
|
||||
}
|
||||
tg3_model_free(&model);
|
||||
tg3_error_stack_free(&errors);
|
||||
return 1;
|
||||
fail:
|
||||
tg3_model_free(&model);
|
||||
tg3_error_stack_free(&errors);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int parse_file_arg(const char *path) {
|
||||
FILE *fp = fopen(path, "rb");
|
||||
uint8_t *buf;
|
||||
@@ -521,5 +747,26 @@ int main(int argc, char **argv) {
|
||||
if (!check_huge_integer_field_rejected()) {
|
||||
return 1;
|
||||
}
|
||||
if (!check_path_traversal_rejected()) {
|
||||
return 1;
|
||||
}
|
||||
if (!check_absolute_uri_rejected()) {
|
||||
return 1;
|
||||
}
|
||||
if (!check_negative_byte_stride_rejected()) {
|
||||
return 1;
|
||||
}
|
||||
if (!check_oob_index_rejected()) {
|
||||
return 1;
|
||||
}
|
||||
if (!check_oob_index_opt_in()) {
|
||||
return 1;
|
||||
}
|
||||
if (!check_extension_index_oob_rejected()) {
|
||||
return 1;
|
||||
}
|
||||
if (!check_error_messages_survive_parse_failure()) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
8
tests/v3/security/negstride.gltf
Normal file
8
tests/v3/security/negstride.gltf
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"asset": {"version": "2.0"},
|
||||
"buffers": [{"byteLength": 4}],
|
||||
"bufferViews": [
|
||||
{"buffer": 0, "byteOffset": 0, "byteLength": 4, "byteStride": -1}
|
||||
],
|
||||
"scenes": [{"nodes": []}]
|
||||
}
|
||||
9
tests/v3/security/oob_buffer_view.gltf
Normal file
9
tests/v3/security/oob_buffer_view.gltf
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"asset": {"version": "2.0"},
|
||||
"buffers": [{"byteLength": 4}],
|
||||
"bufferViews": [{"buffer": 0, "byteOffset": 0, "byteLength": 4}],
|
||||
"accessors": [
|
||||
{"bufferView": 1000000, "byteOffset": 0, "componentType": 5121, "count": 1, "type": "SCALAR"}
|
||||
],
|
||||
"scenes": [{"nodes": []}]
|
||||
}
|
||||
19
tests/v3/security/oob_extension_indices.gltf
Normal file
19
tests/v3/security/oob_extension_indices.gltf
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"asset": {"version": "2.0"},
|
||||
"nodes": [
|
||||
{"extensions": {"KHR_audio": {"emitter": 2147483647},
|
||||
"MSFT_lod": {"ids": [-1, 9999]}}}
|
||||
],
|
||||
"materials": [
|
||||
{"extensions": {"MSFT_lod": {"ids": [-5]}}}
|
||||
],
|
||||
"scenes": [
|
||||
{"extensions": {"KHR_audio": {"emitters": [12345]}}}
|
||||
],
|
||||
"extensions": {
|
||||
"KHR_audio": {
|
||||
"sources": [{"bufferView": -1}],
|
||||
"emitters": [{"source": 99999}]
|
||||
}
|
||||
}
|
||||
}
|
||||
14
tests/v3/security/traversal_relative.gltf
Normal file
14
tests/v3/security/traversal_relative.gltf
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"asset": {"version": "2.0"},
|
||||
"buffers": [
|
||||
{"uri": "../../../../../../../../tmp/tg3-poc-secret.txt", "byteLength": 32}
|
||||
],
|
||||
"bufferViews": [
|
||||
{"buffer": 0, "byteOffset": 0, "byteLength": 32}
|
||||
],
|
||||
"accessors": [
|
||||
{"bufferView": 0, "byteOffset": 0, "componentType": 5121, "count": 32, "type": "SCALAR"}
|
||||
],
|
||||
"scenes": [{"nodes": []}],
|
||||
"scene": 0
|
||||
}
|
||||
Reference in New Issue
Block a user