In
{ err=$(exec 2>&1 >&3; ls -ld /x /bin); exec 0<&3; out=$(cat); } 3>&1
The `{ ... } 3>&1` clones the fd 1 to fd 3. That just means that fd 3 now points to the same resource as what fd 1 pointed to. If you ran that from a terminal, that will probably be a fd open in read+write mode to a terminal device.
After `exec 0<&3`, fds 0, 1, and 3 are all pointing to that same _open file description_ (created when your terminal emulator opened the slave side of the pseudo-terminal pair it created before executing your shell in the case of the command run in the terminal above).
Then in `out=$(cat)`, the `$(...)` changes fd 1 to the writing end of a pipe, while 0 is still the tty device. So `cat` will read from the terminal device, so things you're typing on the keyboard (and if it wasn't a terminal device, you would probably get an error as the fd was probably open in write-only mode).
For `cat` to read what `ls` writes on its stdout, you'd need `ls` stdout and `cat` stdin to be two ends on an IPC mechanism like pipe, socketpair or pseudo-terminal pair. For instance `ls` stdout to be the writing end of a pipe and `cat` stdin to be the reading end.
But you'd also need `ls` and `cat` to run concurrently, not one after the other, as that's an IPC (inter-process communication) mechanism.
Since pipes can _hold_ some data (64 KiB by default on current versions of Linux), you would get away with short outputs if you managed to create that second pipe, but for larger outputs, you'd run into deadlocks, `ls` would hang when the pipe is full and would hang until something empties the pipe, but `cat` can only empty the pipe when `ls` returns.
Also, only `yash` has a raw interface to `pipe()` which you'd need to create that second pipe to read from `ls` stdout (the other pipe for stderr being created by the `$(...)` construct).
In yash, you'd do:
{ out=$(ls -d / /x 2>&3); exec 3>&-; err=$(exec cat <&4); } 3>>|4
Where `3>>|4` (a yash-specific feature) creates that second pipe with the writing end on fd 3 and the reading end on fd 4.
But again, if the stderr output is greater than the size of the pipe, that will hang. We're effectively using the pipe as a temporary file in memory, not a pipe.
To really use pipes, we'd need to start `ls` with stdout being the writing end of one pipe and stderr being the writing end of another pipe, and then the shell read the other ends of those pipes concurrently, as the data comes (not one after the other or again you'd run into dead-locks) to store into the two variables.
To be able to read from those two fds as the data comes, you'd need a shell with `select()`/`poll()` support. `zsh` is such a shell, but it doesn't have `yash`'s _pipeline redirection_ feature¹, so you'd need to use named pipes (so manage their creation, permissions, and cleanup) and use a complex loop with `zselect`/`sysread`... I'll leave that to you as an exercise...
---
<sup>¹ If on Linux, you might be able to use the fact that `/proc/self/fd/x` on a pipe behaves like a named pipe though.</sup>