bind-address = 127.0.0.1 in the MySQL config, see that port 3306 is closed from the outside, and treat the database as safe from brute-force bots. The first half of that is true. MySQL really is no longer listening on a public interface. But if phpMyAdmin or Adminer is reachable on a public URL, anyone on the internet can type a username and password into a web form. The tool then opens the database connection from the server itself, so MySQL sees every attempt as coming from localhost.
This post is written as a post-mortem of a pattern that shows up often on VPS and shared hosting. It is a composite, not a specific incident, so there are no timestamps or customer details. The phases still run in the order you would see them in a real investigation.
Initial impact: the server is busy and the database is supposedly private
Too many connections. CPU load rises with no matching rise in real visitors.
The owner's first reaction is reasonable: the database can't be the target, because it only listens on 127.0.0.1 and the firewall blocks 3306 anyway. Support staff often hear the same thing. That belief sends the investigation toward the web application, the caching layer, or SSH, and away from the actual entry point.
Investigation: ruling out the obvious
conf.d directory, or a panel rewrites them. After that, look at who is hitting web paths that belong to database tools, and whether any such tools are sitting in web roots.
BASH
# 1. Confirm what MySQL/MariaDB actually listens on (read-only)
sudo ss -tlnp | grep -E ':3306|mysqld|mariadbd'
# 2. Count POST requests to common DB tool paths, grouped by client IP
# Log path depends on web server and control panel
sudo grep -iE 'phpmyadmin|/pma|adminer' /var/log/nginx/access.log \
| grep -F 'POST /' \
| awk '{print $1}' | sort | uniq -c | sort -rn | head -20
# 3. Look for stray Adminer files in web roots (adjust the path)
sudo find /var/www -iname 'adminer*.php' 2>/dev/null127.0.0.1:3306 as intended, so the network check passes. SSH logs show the usual background noise of password guessing. That is worth handling on its own, as covered in Hardening Linux Web Servers Against SSH Brute Force and Zero-Day Exploits, but it doesn't explain the database load.
The confusing clue comes from MySQL itself. If failed authentication logging is enabled (it depends on log_error_verbosity in MySQL or log_warnings in MariaDB), the error log fills with Access denied for user lines. Every one of them names localhost as the client host. It looks like something on the server is guessing passwords, which sends people hunting for local malware.
The discovery: localhost was doing the attacker's work
/phpmyadmin/index.php, /pma/, or /adminer.php from many different addresses. Most of them never load a page normally first. They go straight to the login form.
The path from bot to database looks like this:
- A bot scans common URLs for database tools and finds a login page that answers.
- It submits username and password guesses to that page over normal HTTP or HTTPS, which the firewall allows because the web server has to be public.
- phpMyAdmin or Adminer, running as PHP on the same machine, takes those credentials and connects to MySQL through the local socket or 127.0.0.1.
- MySQL sees a login from
localhost, checks the password, and rejects or accepts it.
bind-address only decides which network interfaces MySQL accepts TCP connections on. It has no say in which PHP script on the same box is allowed to connect. From MySQL's point of view nothing unusual happened. A local client asked to log in, which is exactly what the website does all day.
This also explains the resource drain. Each guess costs a PHP worker, a MySQL authentication, and often a TLS handshake. It explains why per-IP rate limits barely help, too. Credential sweeps of this kind are commonly run from botnets made of other compromised Linux servers, so the guesses arrive spread across many addresses.
The worst part is what accounts are exposed. Admins often create 'root'@'localhost' or other powerful users on the logic that localhost-only means safe. A public database tool turns that logic around. The localhost accounts are exactly the ones an outside visitor can now try.
Temporary mitigation: close the door at the web server
\.php$ over plain prefix matches, so an allow/deny rule on a plain prefix block can be silently skipped for exactly the PHP requests you meant to block.
NGINX
# Restrict phpMyAdmin to a trusted address (203.0.113.10 is a documentation IP)
location ^~ /phpmyadmin/ {
allow 203.0.113.10;
deny all;
# Keep PHP handling nested here. With ^~, Nginx skips regex
# locations defined elsewhere, and the nested block inherits
# the allow/deny rules above.
location ~ \.php$ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $request_filename;
# Socket path depends on distro and PHP version
fastcgi_pass unix:/run/php/php-fpm.sock;
}
}nginx -t before reloading. Then test from an address outside the allowlist and confirm you get a 403 on both the directory and a .php URL, not just one of them. On Apache 2.4, the equivalent is a Require ip rule in the Directory or Location section that serves phpMyAdmin. Where that section lives depends on whether phpMyAdmin came from a distro package, a panel, or a manual install. HTTP basic auth on top of the allowlist adds a second layer that bots don't get past by guessing MySQL passwords.
If the investigation shows any POST from an unknown IP followed by normal browsing inside the tool, assume a login succeeded. Rotate the passwords of every database user, starting with the most privileged one, update the application configs that use them, and review the database for unexpected users or changes.
Any Adminer file found by the find command that nobody actively uses should be deleted. These single-file tools are often uploaded for a quick fix and then forgotten in a web root, where they stay reachable for as long as the site exists.
Architectural fix: no public path to the database
BASH
# Forward local port 3307 to MySQL on the server's loopback interface
# -N: don't open a remote shell, just hold the tunnel
ssh -N -L 3307:127.0.0.1:3306 [email protected]
# In another terminal (or a GUI client), connect through the tunnel
mysql -h 127.0.0.1 -P 3307 -u dbuser -pbind-address gave them in the first place.
If a team really needs a web UI, put it on a vhost that listens only on localhost or a VPN interface, and reach it through the same tunnel or the VPN. On servers with a control panel, the bundled phpMyAdmin is often opened through the panel's own login. It is still worth checking for yourself whether it also answers on a direct public path, because that differs between panels and versions.
The last layer is limiting what a successful guess can do. Give each site its own database user with privileges only on its own database. Avoid using root for day-to-day work, and in phpMyAdmin set cfg['Servers'][i]['AllowRoot'] = false; so root can't log in through the web UI even if the page is reachable. Use long, unique passwords for any account a web tool can reach. None of this replaces closing the path, but it turns a full compromise into a contained one.
The practical takeaway: bind-address protects the MySQL port, not MySQL logins. Whenever a server has phpMyAdmin, Adminer, or any web-based database tool, ask one question: can a stranger on the internet load its login page? If the answer is yes, the database is effectively public, whatever the MySQL config says. Check the access logs, restrict or remove the tool, and move admin access behind SSH.