I think before saying “you can't” do something, people should at least give it a try with their own hands…
Simple and clean solution, without using eval or anything exotic
1. A minimal version
{
    IFS=$'\n' read -r -d '' CAPTURED_STDERR;
    IFS=$'\n' read -r -d '' CAPTURED_STDOUT;
} < <((printf '\0%s\0' "$(some_command)" 1>&2) 2>&1)
Requires: printf, read
2. A simple test
A dummy script for producing stdout and stderr: useless.sh
#!/bin/bash
#
# useless.sh
#
echo "This is stderr" 1>&2
echo "This is stdout" 
The actual script that will capture stdout and stderr: capture.sh
#!/bin/bash
#
# capture.sh
#
{
    IFS=$'\n' read -r -d '' CAPTURED_STDERR;
    IFS=$'\n' read -r -d '' CAPTURED_STDOUT;
} < <((printf '\0%s\0' "$(./useless.sh)" 1>&2) 2>&1)
echo 'Here is the captured stdout:'
echo "${CAPTURED_STDOUT}"
echo
echo 'And here is the captured stderr:'
echo "${CAPTURED_STDERR}"
echo
Output of capture.sh
Here is the captured stdout:
This is stdout
And here is the captured stderr:
This is stderr
3. How it works
The command
(printf '\0%s\0' "$(some_command)" 1>&2) 2>&1
sends the standard output of some_command to printf '\0%s\0', thus creating the string \0${stdout}\n\0 (where \0 is a NUL byte and \n is a new line character); the string \0${stdout}\n\0 is then redirected to the standard error, where the standard error of some_command was already present, thus composing the string ${stderr}\n\0${stdout}\n\0, which is then redirected back to the standard output.
Afterwards, the command
IFS=$'\n' read -r -d '' CAPTURED_STDERR;
starts reading the string ${stderr}\n\0${stdout}\n\0 up until the first NUL byte and saves the content into ${CAPTURED_STDERR}. Then the command
IFS=$'\n' read -r -d '' CAPTURED_STDOUT;
keeps reading the same string up to the next NUL byte and saves the content into ${CAPTURED_STDOUT}.
4. Making it unbreakable
The solution above relies on a NUL byte for the delimiter between stderr and stdout, therefore it will not work if for any reason stderr contains other NUL bytes.
Although that will rarely happen, it is possible to make the script completely unbreakable by stripping all possible NUL bytes from stdout and stderr before passing both outputs to read (sanitization) – NUL bytes would anyway get lost, as it is not possible to store them into shell variables:
{
    IFS=$'\n' read -r -d '' CAPTURED_STDOUT;
    IFS=$'\n' read -r -d '' CAPTURED_STDERR;
} < <((printf '\0%s\0' "$((some_command | tr -d '\0') 3>&1- 1>&2- 2>&3- | tr -d '\0')" 1>&2) 2>&1)
Requires: printf, read, tr
5. Preserving the exit status – the blueprint (without sanitization)
After thinking a bit about the ultimate approach, I have come out with a solution that uses printf to cache both stdout and the exit code as two different arguments, so that they never interfere.
The first thing I did was outlining a way to communicate the exit status to the third argument of printf, and this was something very easy to do in its simplest form (i.e. without sanitization).
{
    IFS=$'\n' read -r -d '' CAPTURED_STDERR;
    IFS=$'\n' read -r -d '' CAPTURED_STDOUT;
    (IFS=$'\n' read -r -d '' _ERRNO_; exit ${_ERRNO_});
} < <((printf '\0%s\0%d\0' "$(some_command)" "${?}" 1>&2) 2>&1)
Requires: exit, printf, read
6. Preserving the exit status with sanitization – unbreakable (rewritten)
Things get very messy though when we try to introduce sanitization. Launching tr for sanitizing the streams does in fact overwrite our previous exit status, so apparently the only solution is to redirect the latter to a separate descriptor before it gets lost, keep it there until tr does its job twice, and then redirect it back to its place.
After some quite acrobatic redirections between file descriptors, this is what I came out with.
The code below is a rewrite of a previous example (you can find it in the appendix below). It also sanitizes possible NUL bytes in the streams, so that read can always work properly.
{
    IFS=$'\n' read -r -d '' CAPTURED_STDOUT;
    IFS=$'\n' read -r -d '' CAPTURED_STDERR;
    (IFS=$'\n' read -r -d '' _ERRNO_; exit ${_ERRNO_});
} < <((printf '\0%s\0%d\0' "$(((({ some_command; echo "${?}" 1>&3-; } | tr -d '\0' 1>&4-) 4>&2- 2>&1- | tr -d '\0' 1>&4-) 3>&1- | exit "$(cat)") 4>&1-)" "${?}" 1>&2) 2>&1)
Requires: exit, printf, read, tr
This solution is really robust. The exit code is always kept separated in a different descriptor until it reaches printf directly as a separate argument.
7. The ultimate solution – a general purpose function with exit status
We can also transform the code above to a general purpose function.
# SYNTAX:
#   catch STDOUT_VARIABLE STDERR_VARIABLE COMMAND [ARG1[ ARG2[ ...[ ARGN]]]]
catch() {
    {
        IFS=$'\n' read -r -d '' "${1}";
        IFS=$'\n' read -r -d '' "${2}";
        (IFS=$'\n' read -r -d '' _ERRNO_; return ${_ERRNO_});
    } < <((printf '\0%s\0%d\0' "$(((({ shift 2; "${@}"; echo "${?}" 1>&3-; } | tr -d '\0' 1>&4-) 4>&2- 2>&1- | tr -d '\0' 1>&4-) 3>&1- | exit "$(cat)") 4>&1-)" "${?}" 1>&2) 2>&1)
}
Requires: cat, exit, printf, read, shift, tr
ChangeLog: 2022-06-17 // Replaced ${3} with shift 2; ${@} after Pavel Tankov's comment (Bash-only). 2023-01-18 // Replaced ${@} with "${@}" after cbugk's comment.
With the catch function we can launch the following snippet,
catch MY_STDOUT MY_STDERR './useless.sh'
echo "The \`./useless.sh\` program exited with code ${?}"
echo
echo 'Here is the captured stdout:'
echo "${MY_STDOUT}"
echo
echo 'And here is the captured stderr:'
echo "${MY_STDERR}"
echo
and get the following result:
The `./useless.sh` program exited with code 0
Here is the captured stdout:
This is stderr 1
This is stderr 2
And here is the captured stderr:
This is stdout 1
This is stdout 2
8. What happens in the last examples
Here follows a fast schematization:
- some_commandis launched: we then have- some_command's- stdouton the descriptor 1,- some_command's- stderron the descriptor 2 and- some_command's exit code redirected to the descriptor 3
- stdoutis piped to- tr(sanitization)
- stderris swapped with- stdout(using temporarily the descriptor 4) and piped to- tr(sanitization)
- the exit code (descriptor 3) is swapped with stderr(now descriptor 1) and piped toexit $(cat)
- stderr(now descriptor 3) is redirected to the descriptor 1, end expanded as the second argument of- printf
- the exit code of exit $(cat)is captured by the third argument ofprintf
- the output of printfis redirected to the descriptor 2, wherestdoutwas already present
- the concatenation of stdoutand the output ofprintfis piped toread
9. The POSIX-compliant version #1 (breakable)
Process substitutions (the < <() syntax) are not POSIX-standard (although they de facto are). In a shell that does not support the < <() syntax the only way to reach the same result is via the <<EOF … EOF syntax. Unfortunately this does not allow us to use NUL bytes as delimiters, because these get automatically stripped out before reaching read. We must use a different delimiter. The natural choice falls onto the CTRL+Z character (ASCII character no. 26). Here is a breakable version (outputs must never contain the CTRL+Z character, or otherwise they will get mixed).
_CTRL_Z_=$'\cZ'
{
    IFS=$'\n'"${_CTRL_Z_}" read -r -d "${_CTRL_Z_}" CAPTURED_STDERR;
    IFS=$'\n'"${_CTRL_Z_}" read -r -d "${_CTRL_Z_}" CAPTURED_STDOUT;
    (IFS=$'\n'"${_CTRL_Z_}" read -r -d "${_CTRL_Z_}" _ERRNO_; exit ${_ERRNO_});
} <<EOF
$((printf "${_CTRL_Z_}%s${_CTRL_Z_}%d${_CTRL_Z_}" "$(some_command)" "${?}" 1>&2) 2>&1)
EOF
Requires: exit, printf, read
Note: As shift is Bash-only, in this POSIX-compliant version command + arguments must appear under the same quotes.
10. The POSIX-compliant version #2 (unbreakable, but not as good as the non-POSIX one)
And here is its unbreakable version, directly in function form (if either stdout or stderr contain CTRL+Z characters, the stream will be truncated, but will never be exchanged with another descriptor).
_CTRL_Z_=$'\cZ'
# SYNTAX:
#     catch_posix STDOUT_VARIABLE STDERR_VARIABLE COMMAND
catch_posix() {
    {
        IFS=$'\n'"${_CTRL_Z_}" read -r -d "${_CTRL_Z_}" "${1}";
        IFS=$'\n'"${_CTRL_Z_}" read -r -d "${_CTRL_Z_}" "${2}";
        (IFS=$'\n'"${_CTRL_Z_}" read -r -d "${_CTRL_Z_}" _ERRNO_; return ${_ERRNO_});
    } <<EOF
$((printf "${_CTRL_Z_}%s${_CTRL_Z_}%d${_CTRL_Z_}" "$(((({ ${3}; echo "${?}" 1>&3-; } | cut -z -d"${_CTRL_Z_}" -f1 | tr -d '\0' 1>&4-) 4>&2- 2>&1- | cut -z -d"${_CTRL_Z_}" -f1 | tr -d '\0' 1>&4-) 3>&1- | exit "$(cat)") 4>&1-)" "${?}" 1>&2) 2>&1)
EOF
}
Requires: cat, cut, exit, printf, read, tr
Answer's history
Here is a previous version of catch() before Pavel Tankov's comment (this version requires the additional arguments to be quoted together with the command):
# SYNTAX:
#  catch STDOUT_VARIABLE STDERR_VARIABLE COMMAND [ARG1[ ARG2[ ...[ ARGN]]]]
catch() {
  {
      IFS=$'\n' read -r -d '' "${1}";
      IFS=$'\n' read -r -d '' "${2}";
      (IFS=$'\n' read -r -d '' _ERRNO_; return ${_ERRNO_});
  } < <((printf '\0%s\0%d\0' "$(((({ shift 2; ${@}; echo "${?}" 1>&3-; } | tr -d '\0' 1>&4-) 4>&2- 2>&1- | tr -d '\0' 1>&4-) 3>&1- | exit "$(cat)") 4>&1-)" "${?}" 1>&2) 2>&1)
}
Requires: cat, exit, printf, read, tr
Furthermore, I replaced an old example for propagating the exit status to the current shell, because, as Andy had pointed out in the comments, it was not as “unbreakable” as it was supposed to be (since it did not use printf to buffer one of the streams). For the record I paste the problematic code here:
Preserving the exit status (still unbreakable)
The following variant propagates also the exit status of some_command to the current shell:
{
  IFS= read -r -d '' CAPTURED_STDOUT;
  IFS= read -r -d '' CAPTURED_STDERR;
  (IFS= read -r -d '' CAPTURED_EXIT; exit "${CAPTURED_EXIT}");
} < <((({ { some_command ; echo "${?}" 1>&3; } | tr -d '\0'; printf '\0'; } 2>&1- 1>&4- | tr -d '\0' 1>&4-) 3>&1- | xargs printf '\0%s\0' 1>&4-) 4>&1-)
Requires: printf, read, tr, xargs
Later, Andy submitted the following “suggested edit” for capturing the exit code:
Simple and clean solution saving the exit value
We can add to the end of stderr, a third piece of information, another NUL plus the exit status of the command. It will be outputted after stderr but before stdout
{
  IFS= read -r -d '' CAPTURED_STDERR;
  IFS= read -r -d '' CAPTURED_EXIT;
  IFS= read -r -d '' CAPTURED_STDOUT;
} < <((printf '\0%s\n\0' "$(some_command; printf '\0%d' "${?}" 1>&2)" 1>&2) 2>&1)
His solution seemed to work, but had the minor problem that the exit status needed to be placed as the last fragment of the string, so that we are able to launch exit "${CAPTURED_EXIT}" within round brackets and not pollute the global scope, as I had tried to do in the removed example. The other problem was that, as the output of his innermost printf got immediately appended to the stderr of some_command, we could no more sanitize possible NUL bytes in stderr, because among these now there was also our NUL delimiter.
Trying to find the right solution to this problem was what led me to write § 5. Preserving the exit status – the blueprint (without sanitization), and the following sections.