I'm surprised this has not been found earlier. This problem has been there from the start...
The objective of both DefaultThreadContextStack and DefaultThreadContextMap is to be a copy-on-write implementation, the reasoning being that modifications to the map/stack will be rare, and this will allow us to avoid copying the stack/map for every single log event. Each log event is given a stack/map instance obtained with a call to ThreadContext.getImmutableStack() and ThreadContext.getImmutableMap().
For the map, this method returns the internal immutable map. This is safe because with every modification that map will be replaced with another instance. With the stack we were also returning the internal representation, but as you pointed out, the DefaultThreadContextStack implementation stores the data in a thread-local variable, so instances of this class are not suitable for passing to another thread because that other thread will not be able to access the data.
The solution is to do something similar as we are doing with the map. The thread-local variable should keep an immutable stack instance that can safely be handed off to other threads. Every time the stack is modified, a new stack instance will be created and stored in the thread-local variable.
This requires an API change: we need to add a method ThreadContextStack.getImmutableStackOrNull(), analogous to the existing ThreadContextMap.getImmutableMapOrNull() method.
I doubt many users have custom ThreadContextStack implementations, so I doubt this API change will impact many users (if anyone at all).