Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions bin/pytorch_inference/CBufferedIStreamAdapter.h
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ class CBufferedIStreamAdapter : public caffe2::serialize::ReadAdapterInterface {
std::size_t size() const override;
std::size_t read(std::uint64_t pos, void* buf, std::size_t n, const char* what = "") const override;

//! Read-only view of the buffered model bytes. Valid until this object is
//! destroyed. Used to statically scan the archive before it is handed to
//! torch::jit::load (which would otherwise execute __setstate__ code).
const char* buffer() const { return m_Buffer.get(); }

CBufferedIStreamAdapter(const CBufferedIStreamAdapter&) = delete;
CBufferedIStreamAdapter& operator=(const CBufferedIStreamAdapter&) = delete;

Expand Down
74 changes: 74 additions & 0 deletions bin/pytorch_inference/CModelGraphValidator.cc
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,44 @@

#include <core/CLogger.h>

#include <caffe2/serialize/inline_container.h>
#include <caffe2/serialize/read_adapter_interface.h>
#include <torch/csrc/jit/passes/inliner.h>

#include <algorithm>
#include <cstring>
#include <memory>
#include <string>
#include <vector>

namespace ml {
namespace torch {
namespace {

//! Minimal in-memory ReadAdapterInterface so a PyTorchStreamReader can parse an
//! archive that is already fully buffered in memory, without taking ownership.
class CMemoryReadAdapter final : public caffe2::serialize::ReadAdapterInterface {
public:
CMemoryReadAdapter(const char* data, std::size_t size)
: m_Data{data}, m_Size{size} {}

std::size_t size() const override { return m_Size; }

std::size_t
read(std::uint64_t pos, void* buf, std::size_t n, const char* /*what*/ = "") const override {
if (pos >= m_Size) {
return 0;
}
n = std::min(n, m_Size - static_cast<std::size_t>(pos));
std::memcpy(buf, m_Data + pos, n);
return n;
}

private:
const char* m_Data;
std::size_t m_Size;
};
}

CModelGraphValidator::SResult CModelGraphValidator::validate(const ::torch::jit::Module& module) {

Expand Down Expand Up @@ -95,6 +127,48 @@ void CModelGraphValidator::collectBlockOps(const ::torch::jit::Block& block,
}
}

CModelGraphValidator::TStringVec
CModelGraphValidator::scanArchiveForCustomStateHooks(const char* data, std::size_t size) {
TStringSet hooks;

constexpr std::string_view SETSTATE{"__setstate__"};
constexpr std::string_view GETSTATE{"__getstate__"};

try {
auto adapter = std::make_shared<CMemoryReadAdapter>(data, size);
caffe2::serialize::PyTorchStreamReader reader{adapter};

for (const auto& name : reader.getAllRecords()) {
auto[recordData, recordSize] = reader.getRecord(name);
std::string_view bytes{static_cast<const char*>(recordData.get()), recordSize};

// Reporter remediation (elastic/security#12621): reject any archive
// that embeds custom state hooks. Scan every record — not just
// code/*.py — so hooks cannot hide in debug_pkl / adjacent entries.
if (bytes.find(SETSTATE) != std::string_view::npos) {
hooks.emplace("__setstate__");
}
if (bytes.find(GETSTATE) != std::string_view::npos) {
hooks.emplace("__getstate__");
}
if (hooks.size() == 2) {
break;
}
}
} catch (const std::exception& e) {
// If the archive cannot be parsed as a stream we do not treat this as a
// rejection here: torch::jit::load will attempt the same parse and
// surface a clear error. The post-load graph validator still applies.
LOG_WARN(<< "Pre-load state-hook scan skipped (could not parse archive): "
<< e.what());
return {};
}

TStringVec result{hooks.begin(), hooks.end()};
std::sort(result.begin(), result.end());
return result;
}

void CModelGraphValidator::collectModuleOps(const ::torch::jit::Module& module,
TStringSet& ops,
std::size_t& nodeCount) {
Expand Down
18 changes: 18 additions & 0 deletions bin/pytorch_inference/CModelGraphValidator.h
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

#include <torch/script.h>

#include <cstddef>
#include <string>
#include <string_view>
#include <unordered_set>
Expand Down Expand Up @@ -71,6 +72,23 @@ class CModelGraphValidator {
const std::unordered_set<std::string_view>& allowedOps,
const std::unordered_set<std::string_view>& forbiddenOps);

//! Statically scan a model archive BEFORE torch::jit::load for custom
//! TorchScript state hooks ("__setstate__", "__getstate__"), without
//! executing any of the model's code.
//!
//! Closes the load-time execution gap exploited in elastic/security#12621:
//! torch::jit::load runs a module's __setstate__ during deserialization,
//! before post-load graph validation. Matching the reporter's remediation,
//! any archive containing those literal substrings is rejected. Ops that
//! only run when methods are invoked (e.g. forward) remain the job of the
//! post-load allowlist / forbid checks in validate().
//!
//! \p data / \p size are the raw bytes of the .pt (ZIP) archive. Returns
//! the sorted names of any hooks found, or empty if none are found / the
//! archive cannot be parsed (in which case torch::jit::load will surface
//! the error).
static TStringVec scanArchiveForCustomStateHooks(const char* data, std::size_t size);

private:
//! Collect all operation names from a block, recursing into sub-blocks.
static void collectBlockOps(const ::torch::jit::Block& block,
Expand Down
4 changes: 4 additions & 0 deletions bin/pytorch_inference/CSupportedOperations.cc
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ const CSupportedOperations::TStringViewSet CSupportedOperations::FORBIDDEN_OPERA
"aten::as_strided"sv,
"aten::from_file"sv,
"aten::save"sv,
// Unchecked storage-offset reinterpret (TorchInductor). Same class of OOB
// heap read/write as as_strided; used to bypass the as_strided forbid
// (HackerOne / elastic/security#12242).
"inductor::_reinterpret_tensor"sv,
// After graph inlining, method and function calls should be resolved.
// Their presence indicates an opaque call that cannot be validated.
"prim::CallFunction"sv,
Expand Down
22 changes: 22 additions & 0 deletions bin/pytorch_inference/Main.cc
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,22 @@ void verifySafeModel(const torch::jit::script::Module& module_) {
HANDLE_FATAL(<< "Model graph validation failed: " << e.what());
}
}

//! Reject models with custom TorchScript state hooks BEFORE torch::jit::load.
//! Load executes __setstate__ during deserialization, so post-load graph
//! validation runs too late. Matching elastic/security#12621 / the reporter's
//! remediation, any __setstate__/__getstate__ hooks are refused outright.
//! Forbidden / unrecognised ops in methods that only run when invoked remain
//! the job of verifySafeModel() after a successful load.
//! \p modelData / \p modelSize are the raw bytes of the buffered .pt archive.
void verifySafeModelBeforeLoad(const char* modelData, std::size_t modelSize) {
auto hooks = ml::torch::CModelGraphValidator::scanArchiveForCustomStateHooks(
modelData, modelSize);
if (hooks.empty() == false) {
std::string names = ml::core::CStringUtils::join(hooks, ", ");
HANDLE_FATAL(<< "Model archive contains custom state hooks: " << names);
}
}
}

torch::Tensor infer(torch::jit::script::Module& module_,
Expand Down Expand Up @@ -315,6 +331,12 @@ int main(int argc, char** argv) {
if (readAdapter->init() == false) {
return EXIT_FAILURE;
}
if (skipModelValidation == false) {
// Reject custom state hooks before loading so that __setstate__
// (which torch::jit::load would execute during deserialization)
// never runs. Op allowlisting remains a post-load check.
verifySafeModelBeforeLoad(readAdapter->buffer(), readAdapter->size());
}
module_ = torch::jit::load(std::move(readAdapter));
if (skipModelValidation) {
LOG_WARN(<< "Model graph validation SKIPPED — --skipModelValidation flag is set. "
Expand Down
89 changes: 89 additions & 0 deletions bin/pytorch_inference/unittest/CModelGraphValidatorTest.cc
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,17 @@ bool hasUnrecognisedOp(const CModelGraphValidator::SResult& result, const std::s
return std::find(result.s_UnrecognisedOps.begin(), result.s_UnrecognisedOps.end(),
op) != result.s_UnrecognisedOps.end();
}

std::string readFileBytes(const std::string& path) {
std::ifstream file(path, std::ios::binary);
std::ostringstream buffer;
buffer << file.rdbuf();
return buffer.str();
}

bool scanContains(const CModelGraphValidator::TStringVec& ops, const std::string& op) {
return std::find(ops.begin(), ops.end(), op) != ops.end();
}
}

BOOST_AUTO_TEST_CASE(testMaliciousFileReader) {
Expand Down Expand Up @@ -439,6 +450,84 @@ BOOST_AUTO_TEST_CASE(testMaliciousRopExploit) {
BOOST_REQUIRE(hasForbiddenOp(result, "aten::as_strided"));
}

// --- Pre-load custom state-hook scan tests ---
//
// torch::jit::load executes a module's __setstate__ during deserialization,
// before CModelGraphValidator::validate (which inspects an already-loaded
// module) could run. Matching elastic/security#12621, custom state hooks are
// rejected outright before load. Ops that only run when methods are invoked
// remain covered by the post-load allowlist / forbid checks. These tests
// never call torch::jit::load on attack fixtures.

BOOST_AUTO_TEST_CASE(testPreLoadScanRejectsCustomStateHooks) {
// The HackerOne #12621 RCE uses allowlisted ops inside __setstate__;
// rejecting the hooks themselves is the reporter's remediation.
std::string bytes = readFileBytes("testfiles/malicious_models/malicious_setstate_file_reader.pt");
BOOST_REQUIRE(bytes.empty() == false);

auto hooks = CModelGraphValidator::scanArchiveForCustomStateHooks(
bytes.data(), bytes.size());

BOOST_REQUIRE(scanContains(hooks, "__setstate__"));
BOOST_REQUIRE(scanContains(hooks, "__getstate__"));
}

BOOST_AUTO_TEST_CASE(testPreLoadScanRejectsCustomStateHooksInSubmodule) {
std::string bytes = readFileBytes(
"testfiles/malicious_models/malicious_setstate_file_reader_in_submodule.pt");
BOOST_REQUIRE(bytes.empty() == false);

auto hooks = CModelGraphValidator::scanArchiveForCustomStateHooks(
bytes.data(), bytes.size());

BOOST_REQUIRE(scanContains(hooks, "__setstate__"));
}

BOOST_AUTO_TEST_CASE(testPreLoadScanAcceptsBenignModel) {
// Typical scripted transformers do not embed custom __setstate__/__getstate__.
std::string bytes = readFileBytes("testfiles/e5_with_norm.pt");
BOOST_REQUIRE(bytes.empty() == false);

auto hooks = CModelGraphValidator::scanArchiveForCustomStateHooks(
bytes.data(), bytes.size());

BOOST_REQUIRE(hooks.empty());
}

BOOST_AUTO_TEST_CASE(testPreLoadScanAcceptsForwardOnlyMaliciousModel) {
// Forbidden ops in forward (no custom state hooks) are not a pre-load
// concern — they are caught by post-load validate() after jit::load.
std::string bytes = readFileBytes("testfiles/malicious_models/malicious_file_reader.pt");
BOOST_REQUIRE(bytes.empty() == false);

auto hooks = CModelGraphValidator::scanArchiveForCustomStateHooks(
bytes.data(), bytes.size());

BOOST_REQUIRE(hooks.empty());
}

BOOST_AUTO_TEST_CASE(testPreLoadScanHandlesGarbageInput) {
// Non-archive input must not throw or crash; torch::jit::load will surface
// the real error later. The scan returns empty results.
std::string garbage = "this is not a valid .pt archive";
auto hooks = CModelGraphValidator::scanArchiveForCustomStateHooks(
garbage.data(), garbage.size());
BOOST_REQUIRE(hooks.empty());
}

BOOST_AUTO_TEST_CASE(testMaliciousReinterpretTensorRejectedPostLoad) {
// inductor::_reinterpret_tensor is the as_strided heap-OOB bypass
// (elastic/security#12242). This forward-only fixture carries no custom
// state hooks, so it loads cleanly; post-load validate must then report the
// op as forbidden (not merely unrecognised).
auto module = ::torch::jit::load(
"testfiles/malicious_models/malicious_reinterpret_tensor_oob_read.pt");
auto result = CModelGraphValidator::validate(module);

BOOST_REQUIRE(result.s_IsValid == false);
BOOST_REQUIRE(hasForbiddenOp(result, "inductor::_reinterpret_tensor"));
}

// --- Prepacked model compatibility tests ---
//
// These load TorchScript models that mirror the ops used by Elasticsearch's
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading