ned14 / status-code

Proposed SG14 status_code for the C++ standard

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

UAF error (probably?) with thrown_exception_domain example when exceptions contain dynamic strings

MHebes opened this issue · comments

commented

I was trying to use https://github.com/ned14/status-code/blob/master/example/thrown_exception.cpp to help migrate an existing codebase that sometimes uses exceptions. It's failing for cases where the exception itself owns the storage of it's what() string:

#include "exception_ptr_domain.h"

void throw_something() { throw std::logic_error("oops"); }

int main() {
  try {
    throw_something();
  } catch (std::exception& e) {
    printf("Exception has message %s\n", e.what());
    auto eptr = std::current_exception();
    thrown_exception_code tec = make_status_code(eptr);
    system_code sc(tec);
    printf("Thrown exception code has message %s\n", sc.message().c_str());
    printf("Thrown exception code == errc::not_enough_memory = %d\n",
           sc == errc::not_enough_memory);
  }
  return 0;
}

This is giving garbage out:

Exception has message oops
Thrown exception code has message ▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌�Cû_▌�
Thrown exception code == errc::not_enough_memory = 0

I'm on latest MSVC with C++20. It actually looks like it's getting the right exception and everything:

image

But it seems the exception is destroyed by the time _thrown_exception_domain::_do_message returns:

image

Any idea what's going on here? I get the same result with std::make_exception_ptr, if that helps.

If the C++ exception type is std::runtime_error, does that work okay?

commented

If the C++ exception type is std::runtime_error, does that work okay?

No, if I throw std::runtime_error("oops") instead, I get the same issue. A custom exception also produces the issue:

class MyException : public std::exception {
  std::string owned_string_;

 public:
  MyException(std::string str) : owned_string_(std::move(str)) {}
  [[nodiscard]] const char* what() const override {
    return owned_string_.c_str();
  }
};

void throw_something() { throw MyException("oops"); }

// ...
Exception has message oops
Thrown exception code has message ê|╔┘°
Thrown exception code == errc::not_enough_memory = 0

I think this is a side effect of P1675: std::rethrow_exception is allowed to create a temporary copy of the exception object and throw that instead of the original object.

I added your repro to the test suite. I cannot repro it on Linux with GCC nor on Windows with MSVC. Address sanitiser is clean on both.

You sure you're on latest status-code and not an older version? There were bug fixes here in the recent past.

I have updated the test to reliably reproduce the issue on MSVC 19.36.32537.0 (note that the CI doesn't run the test executable):

Exception has message oops
strcmp(msg.c_str(), "oops") == 0 failed at line 67
Thrown exception code has message `Xü¡Î�
commented

I think this is a side effect of P1675: std::rethrow_exception is allowed to create a temporary copy of the exception object and throw that instead of the original object.

I think you're right. If I add printfs like Herb Stutter does in that:

// main.cpp
// ...
int main() {
  try {
    throw_something();
  } catch (std::exception& e) {
    printf("main() caught with address %llu\n", (void*)&e);
// ...

// exception_ptr_domain.h
// ...
  virtual _base::string_ref _do_message(
      const status_code<void>& code) const noexcept override final {
// ...
    } catch (const std::exception& x) {
      printf("_do_message() caught with address %llu\n", (void*)&x);
      return _base::string_ref(x.what());
// ...

The exception objects have different addresses:

main() caught with address 583730722496
Exception has message oops
_do_message() caught with address 583730712576
Thrown exception code has message ▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌+TáÅ▌$
Thrown exception code == errc::not_enough_memory = 0

Testing reveals that their what strings have different addresses as well.

commented

I had success modifying the exception_ptr_storage_t to use shared_ptr<exception> instead of exception_ptr:

diff --git a/exception_ptr_domain.h b/exception_ptr_domain.h
index 64465fe..594df6d 100644
--- a/exception_ptr_domain.h
+++ b/exception_ptr_domain.h
@@ -26,6 +26,7 @@ http://www.boost.org/LICENSE_1_0.txt)
 #include <stdio.h>  // for sprintf
 
 #include <exception>
+#include <memory>
 #include <mutex>
 
 #include "outcome-experimental.hpp"
@@ -35,20 +36,34 @@ static constexpr size_t max_exception_ptrs = 16;
 using namespace SYSTEM_ERROR2_NAMESPACE;
 
 struct exception_ptr_storage_t {
+  class unknown_exception : public std::exception {
+   public:
+    const char* what() const override { return "unknown thrown exception"; }
+  };
+
   using index_type = unsigned int;
+  using value_type = std::shared_ptr<std::exception>;
 
   mutable std::mutex lock;
-  std::exception_ptr items[max_exception_ptrs];
+  value_type items[max_exception_ptrs];
   index_type idx{0};
 
-  std::exception_ptr operator[](index_type i) const {
+  value_type operator[](index_type i) const {
     std::lock_guard h(lock);
     return (idx - i < max_exception_ptrs) ? items[i % max_exception_ptrs]
-                                          : std::exception_ptr();
+                                          : value_type();
   }
   index_type add(std::exception_ptr p) {
+    std::shared_ptr<std::exception> sptr;
+    try {
+      std::rethrow_exception(p);
+    } catch (std::exception& e) {
+      sptr = std::make_shared<std::exception>(e);
+    } catch (...) {
+      sptr = std::make_shared<unknown_exception>();
+    }
     std::lock_guard h(lock);
-    items[idx] = std::move(p);
+    items[idx] = std::move(sptr);
     return idx++;
   }
 };
@@ -107,9 +122,9 @@ class _thrown_exception_domain : public status_code_domain {
   // It is surely hideously slow, but that's all relative in the end
   static errc _to_generic_code(value_type c) noexcept {
     try {
-      std::exception_ptr e = exception_ptr_storage[c];
+      std::shared_ptr<std::exception> e = exception_ptr_storage[c];
       if (!e) return errc::unknown;
-      std::rethrow_exception(e);
+      throw *e;
     } catch (const std::invalid_argument& /*unused*/) {
       return errc::invalid_argument;
     } catch (const std::domain_error& /*unused*/) {
@@ -180,23 +195,17 @@ class _thrown_exception_domain : public status_code_domain {
       const status_code<void>& code) const noexcept override final {
     assert(code.domain() == *this);
     const auto& c = static_cast<const thrown_exception_code&>(code);
-    try {
-      std::exception_ptr e = exception_ptr_storage[c.value()];
-      if (!e) return _base::string_ref("expired");
-      std::rethrow_exception(e);
-    } catch (const std::exception& x) {
-      return _base::string_ref(x.what());
-    } catch (...) {
-      return _base::string_ref("unknown thrown exception");
-    }
+    std::shared_ptr<std::exception> e = exception_ptr_storage[c.value()];
+    if (!e) return _base::string_ref("expired");
+    return _base::string_ref(e->what());
   }
   // Throw the code as a C++ exception
   virtual void _do_throw_exception(
       const status_code<void>& code) const override final {
     assert(code.domain() == *this);
     const auto& c = static_cast<const thrown_exception_code&>(code);
-    std::exception_ptr e = exception_ptr_storage[c.value()];
-    std::rethrow_exception(e);
+    std::shared_ptr<std::exception> e = exception_ptr_storage[c.value()];
+    throw *e;
   }
 };

This gives the expected results:

Exception has message oops
Thrown exception code has message oops
Thrown exception code == errc::not_enough_memory = 0

sptr = std::make_shared<std::exception>(e);

That's going to slice the exception object which will neither preserve the vtable nor the exception message (you can nicely observe this in this example). It still drives me nuts that std::exception has a public copy constructor and a public copy assignment operator.

commented

sptr = std::make_shared<std::exception>(e);

That's going to slice the exception object which will neither preserve the vtable nor the exception message (you can nicely observe this in this example). It still drives me nuts that std::exception has a public copy constructor and a public copy assignment operator.

You're right. Darn. I guess that's the problem exception_ptr was supposed to solve in the first place. Not sure how to solve this then.

I guess as a reduced-functionality version I could save a atomic_refcounted_string_ref of the what-string in the global storage instead of the whole object.

commented

Wait, I think I've confused myself—why would the what-strings have different addresses in the first place? The logic_error points to a static lifetime char array. Even if current_exception and rethrow_exception are copying the exception object, surely they'd all be pointing to the same part of the .data section?

I still need to test @BurningEnlightenment 's repro which actually repros. Chances are it'll be next week. But surely this is solvable, I cannot believe that std::exception_ptr drops the storage backing what() in a production compiler.

commented

No rush of course. Also for what it's worth, on my machine, it is behaving correctly in release mode (possibly accidentally?).

$ ./bin/Release/test-issue0056.exe
Exception has message oops
Thrown exception code has message oops

$ ./bin/Debug/test-issue0056.exe
Exception has message oops
strcmp(msg.c_str(), "oops") == 0 failed at line 67
Thrown exception code has message ▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌R■Q6▌$

Edit: Probably a fluke that it worked in release mode. If I throw something with owned data:

class my_exception : public std::exception
{
  std::string str_;
public:
  my_exception(std::string str) : str_(str) {}
  [[nodiscard]] const char *what() const override { return str_.c_str(); }
};

void throw_something() { throw my_exception("oops"); }

It prints Thrown exception code has message with no message in both debug and release.

Probably a fluke that it worked in release mode.

FYA the release builds of my uploaded repro don't trigger the issue until you run it with a debugger attached. Guess I found a way to sniff out whether running under a debugger or not 😁

The logic_error points to a static lifetime char array.

Oh and by the way that doesn't hold on MSVC. On that platform std::exception has a non-standard constructor exception(char const *msg) which copies msg to a newly allocated heap array which is bound to the lifetime of said exception object and gets copied to a newly allocated heap array on copy construction. And every std exception uses that constructor/logic 🥲

Yep so the issue was MSVC makes fresh new copies of exception ptrs every time you rethrow them, and of course the string returned then vanishes as soon as the try block exits. Fixed by having the example copy the string on Windows only. Let me know if this fixes the problem for you.

commented

Yep so the issue was MSVC makes fresh new copies of exception ptrs every time you rethrow them, and of course the string returned then vanishes as soon as the try block exits. Fixed by having the example copy the string on Windows only. Let me know if this fixes the problem for you.

Seems to work great. Thanks for the fix!

Thanks for the BR!