Guide · Malware outside public_html

How to find hidden malware in a Linux home directory

The site has been cleaned, the scanners report clean, and something keeps rewriting files anyway — or a host or a neighbouring account has been flagged, and you need to know what sits in the hosting account outside the web root. This guide covers where a backdoor hides in a Linux or cPanel home directory, the read-only commands that surface it, which dot-directories are normal, how to review SSH keys for every user, how to confirm a file without running it, and the order to remove it in.

Symptom
Files keep changing after cleanups; scanners report clean; host or neighbour flagged
Where it hides
Dot-directories, ~/.ssh, cron, /tmp, /dev/shm — above the document root
First step
Read-only enumeration from root across every account, then preserve
Risk
A shell or SSH key that survives every WordPress cleanup and password change

Why it is there

Why malware hides outside public_html, above the web root

On a Linux web server every hosting account is a user with a home directory. The website lives in one folder of it — public_html on cPanel — but the account user can write to all of it, and so can anyone who gets code execution through the site or steals the account’s SFTP or panel password. The home directory is the most useful place to put something that should last.

It lasts because nothing that looks after the website looks there. A WordPress security plugin scans the WordPress installation; a reinstall replaces files inside public_html; a site restore often restores only the site. None of them touches ~/.config, ~/.ssh, or a crontab. A file above the web root is not reachable over HTTP, so nothing that crawls URLs will request it, and a leading dot keeps a directory out of FTP and File Manager listings unless hidden files are shown.

No root access is needed. A binary owned by the account user can be placed and run with exactly the rights the compromised site already had — including the right to keep rewriting the site’s files after every cleanup.

Table of locations in a cPanel account and on the server — public_html, hidden dot-directories in the home, the user crontab, SSH keys, and /tmp or /dev/shm — showing that a WordPress plugin reads only public_html, a server scanner reads files in the home, and only a root-level review covers all of them.
Figure 1 · Who reads what in an account home

In the 261-account case the backdoor sat in ~/.config/htop/defunct: 1,388,544 bytes, mode 0700, owned by the cPanel user — outside everything a WordPress plugin reads.

Time windows

Find recently modified files on a hacked Linux server

Start from time, not filenames. Attackers rename and relocate; they rarely control every timestamp. A window taken from files you know were changed turns a whole-server search into a short list.

  1. 01

    Take the window from known-bad files

    Run stat -c '%y %n' on a handful of the files a scanner already reported and widen by a few days either side. In the 261-account case, the rewrite times of the 283 infected plugin and theme files defined the window.

  2. 02

    List everything changed in that window, outside public_html

    find /home -type f -newermt '2026-08-20' ! -newermt '2026-08-27' -not -path '*/public_html/*' -ls 2>/dev/null. -newermt compares modification time with a date or 'YYYY-MM-DD HH:MM'; the ! means “not newer than”. Use your own dates.

  3. 03

    Repeat it on change time, which is harder to fake

    find /home -type f -newerct '2026-08-20' ! -newerct '2026-08-27' -not -path '*/public_html/*' -ls 2>/dev/null. touch can set modification time to any date; change time (ctime) is set by the kernel on every content or metadata change, and ordinary users cannot set it. An old mtime with a recent ctime deserves a closer look.

  4. 04

    Use relative windows for a first pass

    find /home -type f -mtime -7 -not -path '*/public_html/*' -ls lists files modified in the last seven days; -mmin -120 narrows to the last two hours. Useful when you only know roughly when it started.

Executables and ELF binaries

Find executables in a home directory on Linux

A hosting account that serves web pages, mail, and configuration has very few reasons to hold a compiled program, which makes this one of the highest-yield checks there is.

  1. 01

    User-owned executables outside the web root

    find /home -type f -perm -u+x -size +100k -not -path '*/public_html/*' -not -path '*/node_modules/*' -ls 2>/dev/null. Small scripts fall under the size limit on purpose; run it again without -size once the large results are explained.

  2. 02

    ELF binaries, whatever their permissions or name

    find /home -type f -size +10k -not -path '*/public_html/*' -not -path '*/node_modules/*' -exec file {} + 2>/dev/null | grep ELF. file reads each file’s header and runs nothing. It catches a binary stored without its execute bit or renamed to .jpg. Run it off-peak.

  3. 03

    Explain every result

    Expect some legitimate hits: wp-cli.phar and composer.phar (PHP archives, which file does not report as ELF), user tooling in ~/.local/bin or ~/bin, and on CloudLinux the Node.js and Python selector environments under ~/nodevenv and ~/virtualenv. Each result needs an owner you can name.

Dot-directories

Hidden dot-directories in a cPanel home: which are normal

A healthy cPanel account has a dozen or more hidden directories. The table lists what the common ones normally are. Names and contents vary with cPanel version, CloudLinux, and installed software — verify against your panel and a known-good account on the same server.

The quickest way to find the outlier is to count names across accounts rather than read each one. find /home -mindepth 2 -maxdepth 2 -name '.*' -type d -printf '%f\n' | sort | uniq -c | sort -n lists every top-level dot-directory name with the number of accounts that have it. Names that appear in nearly every account are usually the panel; names that appear in one or two are where to start.

Watch for names built to be skimmed past: a real tool’s name in the wrong place, a name one character off a normal one, a directory called “.. ” with a trailing space, and a .bash_history linked to /dev/null so no shell history is kept.

DirectoryWhat it normally isWhat deserves a second look
.cpanelcPanel’s per-account data: caches, settings, and datastore files.Executables. The panel writes data here, not programs.
.cphordeHorde webmail data. May remain on older accounts after Horde was removed from cPanel.Recent changes where nobody uses Horde.
.softaculousRecords of apps installed through Softaculous. Some data files carry a .php extension.Differences from a clean account; anything executable.
.wp-cliWP-CLI’s per-user cache, configuration, and installed packages.Present where nobody uses WP-CLI; binaries.
.cachePer-user cache for tools such as Composer or pip.Executables; folders for tools the account does not use.
.localPer-user data, and in ~/.local/bin, scripts installed with pip --user or similar.ELF binaries with no matching installed tool.
.configPer-user configuration for command-line tools, such as ~/.config/htop.Anything but small text config files. ~/.config/htop/defunct is the documented gsocket path.
.sshauthorized_keys, known_hosts, and any key pairs imported through cPanel’s SSH Access.A key nobody added, a second authorized_keys2 file, or any executable at all.
.trashFiles deleted through cPanel File Manager, kept until the trash is emptied.Check it: malware deleted through File Manager is often still here.
.htpasswdsPassword files for cPanel Directory Privacy (password-protected folders).Password files for folders nobody protected.
.spamassassinSpamAssassin per-user preferences (user_prefs) and Bayes data.Executables; changes unrelated to spam settings.
.cagefsOn CloudLinux with CageFS, per-user runtime data, including the user’s private /tmp.Executables in its tmp folder.

SSH keys

Unknown SSH key in authorized_keys: check every user

An SSH key is a backdoor with no file for a scanner to find. It survives every password change, reinstall, and malware cleanup until somebody removes it. Review it for every account, not only the flagged one.

  1. 01

    List every authorized_keys file on the server

    find /home /root -maxdepth 3 -path '*/.ssh/*' -name 'authorized_keys*' -ls 2>/dev/null. The glob also catches authorized_keys2, which OpenSSH’s built-in default still reads unless AuthorizedKeysFile is set. A recent modification time on an account nobody administers is a finding by itself.

  2. 02

    Confirm where sshd actually looks

    sshd -T | grep -i authorizedkeys prints the effective AuthorizedKeysFile and any AuthorizedKeysCommand. If either is set, keys may be read from somewhere else.

  3. 03

    Fingerprint each key

    ssh-keygen -lf /home/USER/.ssh/authorized_keys prints the type, fingerprint, and comment of every key in the file. The comment is free text an attacker can set to your own name — match fingerprints, not labels.

  4. 04

    Read the options in front of each key

    Look for command=, from=, or environment= before a key. A forced command can be a legitimate backup key — or a way to run something every time the key is used.

  5. 05

    Find out whether the key was used

    grep 'Accepted publickey' /var/log/secure* on AlmaLinux, Rocky, or CloudLinux (/var/log/auth.log* on Debian and Ubuntu). Modern OpenSSH logs the user, source address, and fingerprint of the key that authenticated — enough to tell a staged key from one that has been used.

Temp and mail paths

Check /tmp, /var/tmp, /dev/shm and mail directories

World-writable paths are where payloads are staged and run from; mail directories are where nobody looks.

  1. 01

    Executables and hidden entries in temp paths

    find /tmp /var/tmp /dev/shm -xdev \( -type f -perm /111 -o -name '.*' \) -ls 2>/dev/null lists files with any execute bit and anything hidden. On CloudLinux with CageFS, each user’s /tmp is under ~/.cagefs/tmp, and cPanel accounts also have their own ~/tmp — include both.

  2. 02

    Check whether /tmp is mounted noexec

    findmnt -no OPTIONS -T /tmp. noexec (on cPanel servers often set by cPanel’s securetmp script) blocks direct execution of a binary from /tmp, but not a script passed to an interpreter, and not a payload copied into a home directory first.

  3. 03

    Preserve /dev/shm before any reboot

    /dev/shm is held in memory. A reboot erases whatever was staged there, so copy anything suspicious out first.

  4. 04

    Mail directories

    cPanel keeps mail in ~/mail/<domain>/<mailbox>/ in Maildir format: cur, new, and tmp folders of message files. An executable or an ELF binary anywhere under ~/mail does not belong. Mailboxes you did not create show up in ~/etc/<domain>/passwd.

Confirm it

How to confirm a file is malware without running it

Establish what the file is from its properties. Do not run it, chmod it, or open it in anything that might execute it.

file

file /path/to/it reports the type from the file’s own header: ELF executable, shell script, PHP, archive, or data. An ELF binary in a hosting account’s dot-directory is a finding by itself.

stat

stat /path/to/it gives owner, mode, size, and timestamps. Mode 0700, owned by the hosting user, created inside your window, is the profile from the documented case.

sha256sum

sha256sum /path/to/it produces the hash to record and compare. The gsocket sample from the 261-account server was 745fc2346e9ad0fdd00104c6d8732ecf7759cb053bd61c296c7b26e9ddb98d50. A match confirms that build; a non-match proves nothing, because binaries are trivially repacked.

strings, not ldd

strings -n 8 /path/to/it | less shows readable text inside the binary, such as hostnames and paths. Do not run ldd on an untrusted binary: it can execute the program to resolve its libraries.

Search the hash before uploading

Look the SHA-256 up on a multi-engine scanner before uploading the file. Uploads are shared with the service’s subscribers, and a key file may contain your secrets.

Running processes

ls -l /proc/[0-9]*/exe 2>/dev/null | grep deleted finds processes whose executable has been removed from disk. cp /proc/PID/exe /root/evidence/PID.exe recovers a copy of it without running anything.

Remove it

Found a backdoor? Preserve, contain, rotate, then delete

Deleting first feels like progress. It destroys the evidence that answers whether the file ran, what it reached, and whether a copy exists elsewhere — and it does nothing about the access that placed it.

  1. 01

    Preserve

    mkdir -p /root/evidence, then cp -p --parents /home/USER/.config/htop/defunct /root/evidence/ keeps the file with its mode and timestamps. Save its stat and sha256sum output, export the account’s crontab, and copy the SSH, web, mail, and panel logs while they exist.

  2. 02

    Contain

    Suspend or restrict the account, block its outbound connections at the firewall, disable its cron, and record then stop its processes.

  3. 03

    Rotate

    From a device that is not part of the incident: panel and SFTP passwords, SSH keys and authorized_keys entries, database users, WordPress administrators and application passwords, panel API tokens, and every secret in the account’s wp-config.php and .env files.

  4. 04

    Delete

    Only now remove the binary, any key file beside it, and every cron entry, shell-profile line, or service unit that referenced it.

  5. 05

    Rebuild and verify

    Replace the application from verified sources, then repeat the sweep days later, once cron and real traffic have run again.

Every account

How to sweep every cPanel account on a WHM server

One finding in one account on a shared server is a server-level question until the same method has been applied to every account. Run the sweep as root, write one output file per account, and compare them — the account that differs from its neighbours is the one to open first.

getent passwd | awk -F: '$6 ~ "^/home" {print $1, $6}' lists users with a home under /home; on cPanel, /var/cpanel/users/ holds one file per account, and some servers also use /home2 or later — adjust the paths to your layout. For each account, run the time-window, executable, ELF, and authorized_keys checks above, list its crontab with crontab -l -u USER, and check ls -la /var/spool/cron for crontab files belonging to users you did not expect.

Work in priority order

Accounts that share an owner, a deployment routine, or a backup source with the flagged one first; then the rest.

Correlate on time, not names

The same timestamp cluster in several accounts is a stronger signal than the same filename.

Escalate when it repeats

The same artifact, path, or time cluster in more than one account turns an account incident into a server incident — and possibly a rebuild onto a fresh host.

Record what you cannot tell

No cron entry does not prove a binary never ran, and short log retention rarely proves an entry path. Record those as undetermined.

Common questions

Common questions about hidden malware in a home directory

How do I find hidden files in a Linux home directory?

ls -la ~ shows hidden entries in one directory. To search a whole home, run find /home/USER -name '.*' -ls, and to see hidden directories across every account, find /home -mindepth 2 -maxdepth 2 -name '.*' -type d. In cPanel File Manager, open Settings and enable “Show Hidden Files (dotfiles)”.

How do I find malware outside public_html?

Search the account home with public_html excluded: files changed in your incident window (find -newermt), user-owned executables (-perm -u+x), and ELF binaries (file). Then review ~/.ssh/authorized_keys, the crontab, /tmp, /dev/shm, and the mail directories.

Is a hidden folder in my cPanel home directory malware?

Usually not. A normal cPanel account has many — .cpanel, .softaculous, .spamassassin, .trash, and others. Compare the names against a clean account on the same server and look inside for executables or recent changes.

How do I find recently modified files on a hacked Linux server?

Use find with a date window: find /home -type f -newermt 'START' ! -newermt 'END' -ls. Repeat with -newerct, because change time cannot be set back with touch. Take the window from files you already know were infected, then widen it by a few days.

How do I find a backdoor on a Linux server?

Work from root and check what a site scanner cannot: executables and ELF binaries outside the web root, every authorized_keys file, every crontab and systemd timer, processes running from deleted files, and outbound connections (ss -tnp). Preserve before removing.

There is an SSH key in authorized_keys that I do not recognise. What should I do?

Treat it as active access. Record the key line, its fingerprint (ssh-keygen -lf), and the file’s timestamps, and search the SSH log for Accepted publickey with that fingerprint. Then remove it, rotate the account’s credentials, and find what wrote it.

Will ImunifyAV or Wordfence find malware in my home directory?

Wordfence scans the WordPress installation, not the account home above it. ImunifyAV can read the home directory, but a packed binary may only raise a heuristic such as SMW-HEUR-ELF, and SSH keys and cron entries are not malware in a file.

Choose the response path

The containment plan should match the size of the incident.