AlmaLinuxHardenedWP - hpaluch/hpaluch.github.io GitHub Wiki
Project goal: make hardened install of WordPress on AlmaLinux 9. So even when somebody breaks into it we will limit damage...
Caution
WordPress and its plugins are infamous for vast amount of security bugs. This guide is WITHOUT ANY WARRANTY! Use it on your own risk!
Status: work in progress
Optional: rename ethernet interface to eth0
- follow: AlmaLinux9.
Ensure that SELinux is "enabled", "enforcing" and "targeted":
$ sudo sestatus | grep -E '(policy name|Current|SELinux status)'
SELinux status: enabled
Loaded policy name: targeted
Current mode: enforcing
EPEL is repository that contains many packages from Fedora ported to RHEL (and clones). We can enable it following: https://wiki.almalinux.org/repos/Extras
dnf install epel-release
# and install pwgen - password generator
sudo dnf install pwgen
# install manual pages
sudo dnf install man-pages
Example of package "wordpress" that is only in EPEL (but not in stock RHEL distribution):
$ dnf repoquery --location wordpress
https://ftp.sh.cvut.cz/fedora/epel/9/Everything/x86_64/Packages/w/wordpress-6.7.1-1.el9.noarch.rpm
Notice epel
in package URL.
Note: this chapter is not security measure, but traditional way how to get single
firewall.log in the age of decay, err, systemd...
Now systemd-journald
logs everything to single binary log that is hard to
search and scale. As I pointed out on AlmaLinux9#firewalld-logging, to
just read firewall messages you need command like:
$ sudo SYSTEMD_COLORS=false journalctl -k -p 4 -g 'filter_.* MAC=' --no-pager
So we will reconfigure it to use rsyslog
to log firewall messages to
/var/log/firewall.log
and all kernel messages (except firewall) to
/var/log/kernel.log
NOTE: Default /etc/rsyslog.conf
requires that all files in /etc/rsyslog.d/
have extension .conf
Create /etc/rsyslog.d/firewall.conf
to log denied packets into /var/log/firewall
:
# firewall messages into separate file and stop their further processing
#
if ($syslogfacility-text == 'kern') and \
($msg contains 'IN=' and $msg contains 'OUT=') \
then {
-/var/log/firewall.log
stop
}
Create file /etc/rsyslog.d/zz_kernel.conf
to log all kernel messages (but not firewall)
to /var/log/kernel.log
:
#
# log all kernel messages (but firewalld) to /var/log/kernel.log
#
if ($syslogfacility-text == 'kern') and \
($syslogseverity <= 5 /* notice */) \
then {
-/var/log/kernel.log
stop
}
Now verify if systemd is using /dev/ksmg
- kernel messages device:
$ sudo lsof /dev/kmsg
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
systemd 1 root 3w CHR 1,11 0t0 10 /dev/kmsg
systemd-j 482 root 6w CHR 1,11 0t0 10 /dev/kmsg
systemd-j 482 root 8u CHR 1,11 0t0 10 /dev/kmsg
-
to release read
/dev/ksmg
from grip of systemd-journald we have to edit/etc/systemd/journald.conf
and uncomment line:ReadKMsg=no ForwardToKMsg=no
-
WARNING!
systemd-journald
will still use/dev/kmsg
for writing - thanks Lenart! https://github.com/systemd/systemd/issues/33975 However it will not cause problem (we need only single process to read this device) -
now ensure that rsyslog can open
/dev/kmsg
-
edit
/etc/rsyslog.conf
and uncomment:module(load="imklog")
Restart both:
sudo systemctl restart systemd-journald
sudo systemctl restart rsyslog
Tip: see my guide on AlmaLinux9 for detailed description of Firewalld.
Ensure that firewalld is enabled:
$ sudo firewall-cmd --state
running
Enable logging of rejected connections on unicast
addresses (single address targets):
$ sudo firewall-cmd --set-log-denied=unicast
Note: there is no --permanent
option in above command - it is always applied immediately.
Now look which zone is assigned to net interface and which services are enabled:
# firewall-cmd --get-active-zones
public
interfaces: eth0
# firewall-cmd --info-zone=public
public (active)
target: default
icmp-block-inversion: no
interfaces: eth0
sources:
services: cockpit dhcpv6-client ssh
ports:
protocols:
forward: yes
masquerade: no
forward-ports:
source-ports:
icmp-blocks:
rich rules:
We should remove cockipt
(Web Admin UI on tcp port 9090) using and
also dhcpv6-client
(unless you use it):
# Runtime change (applied immediatelly but lost on reload/restart):
firewall-cmd --zone=public --remove-service=cockpit --remove-service=dhcpv6-client
# apply all runtime changes permanently:
firewall-cmd --runtime-to-permanent
SSHd is primary target of attackers so we will limit both usernames and source IP addresses that can connect.
I strongly recommend
- restrict logins to SSH keys only (no password authentication allowed)
- disable root login completely
- restrict logins to known usernames and ip addresses.
Create /etc/ssh/sshd_config.d/99-local.conf
with contents like:
AllowTcpForwarding no
PasswordAuthentication no
PubkeyAuthentication yes
PermitRootLogin no
AllowUsers [email protected]
Note: you will likely need to update last line (AllowUsers) to fit your username and source IP address..
Now validate configuration with: sudo /usr/sbin/sshd -t
Restart ssh - be careful to not cut yourself from server!
sudo systemctl restart sshd
And immediately try login to your AlmaLinux to ensure that SSH is running and still allowing access.
Strongly recommended: try to also login from disabled machine and/or using another (disabled) username.
We should also restrict ssh access at Firewall level.
At firewall level SSH access is allowed from anywhere by default which is
dangerous in today world. There may exists 0-day vulnerability in SSHd that
could be exploited before it evaluate our AllowUsers
rule.
To reduce this risk we will also limit SSH connections at Firewall level...
We will follow: https://serverfault.com/a/684739 just to source address 192.168.1.122:
# run all commands below as 'root':
# Zone creation must be always "permanent":
firewall-cmd --new-zone=trusted-in --permanent
firewall-cmd --permanent --zone=trusted-in --permanent --add-source=192.168.122.1/32
firewall-cmd --permanent --zone=trusted-in --permanent --add-service=ssh
# must remove ssh service from public zone:
firewall-cmd --permanent --zone=public --permanent --remove-service ssh
# double-check above rules and apply them:
firewall-cmd --reload
Notice that we now have 2 active zones (but public
has higher priority, unless redefined):
$ sudo firewall-cmd --get-active-zones
public
interfaces: eth0
trusted-in
sources: 192.168.122.1/32
One overlooked risk is in allowing all outgoing connections to any port and/or any IP address (default):
- typical exploit downloads payload from remote server
- it often connects to remote command center or upload sensitive data to remote server
So we should restrict outgoing connections for most of time, unless they are temporarily needed (for example to download Updates).
We will follow modified example from: https://access.redhat.com/solutions/7013886
I rather created shell script restrict_out.sh
with contents:
#!/bin/bash
set -xeuo pipefail
# https://access.redhat.com/solutions/7013886
firewall-cmd --new-policy out --permanent
firewall-cmd --set-target REJECT --policy out --permanent
firewall-cmd --policy out --add-egress-zone ANY --permanent
firewall-cmd --policy out --add-ingress-zone HOST --permanent
# allow non-recursive outgoing DNS connection
firewall-cmd --policy=out \
--add-rich-rule='rule family="ipv4" destination address="192.168.122.1" service name="dns" accept' --permanent
# if you are running DHCPv4 client you should allow also this rule for Renewal (non-broadcast IP):
firewall-cmd --policy=out \
--add-rich-rule='rule family="ipv4" destination address="192.168.122.1" service name="dhcp" accept' --permanent
# beware - this will apply pending changes
firewall-cmd --reload
exit 0
Caution
Create snapshot or any other kind of backup before running this script - there is high risk that you will make system unbootable or unreachable!
Also note that above rules expect that DNS on 192.168.122.1 is recursive (it completely answers all DNS requests. There also exists non-recursive DNS, which just points client to parent DNS server - so client will need unlimited access to any DNS - this will not work with above rule.
Only after backup run above script as root.
Now watch via dmesg
or tail -f /var/log/firewall.log
messages
with filter_OUT_policy_out_REJECT
- packets rejected via our policy.
In my case there were random connections to NTP servers - recognized
by pairs: PROTO=UDP DPT=123
.
I "resolved" it by disabling NTP (not recommended for production!):
$ sudo dnf remove chrony
This should get rid of above NTP connections.
Now moment of truth: try reboot - in my case it worked properly even with DHCP.
Note: there are rejected some ICMPv6:
... PROTO=ICMPv6 TYPE=133
According to https://www.cisco.com/c/en/us/support/docs/ip/routing-information-protocol-rip/22974-icmpv6codes.html it is:
Hosts send router solicitations messages in order to prompt routers to generate router advertisements messages quickly.
So it is not essential (typical IPv6 router sends RA anyway every 10s).
How to temporarily enable and disable outgoing http(s) access. Example:
sudo firewall-cmd --policy=out --add-service=https --add-service=http
# outgoint http(s) is now enabled, try:
sudo dnf install ncdu
# again disable outgoine http(s)
firewall-cmd --policy=out --remove-service=https --remove-service=http
I strongly recommend to create wrapper script proxy_command.sh
with contents:
#!/bin/bash
# Temporarily enable outgoing https(s) for provided command.
set -euo pipefail
function enable_out_conn
{
local cmd="firewall-cmd --policy=out --add-service=https --add-service=http"
echo -n "INFO: Enabling output connections: $cmd"
$cmd
}
function disable_out_conn
{
local cmd="firewall-cmd --policy=out --remove-service=https --remove-service=http"
echo -n "INFO: Disabling output connections: $cmd"
$cmd
}
function exit_handler
{
disable_out_conn
echo "INFO: Policy 'out' on exit:"
firewall-cmd --info-policy=out
}
[ $# -gt 0 ] || {
echo "ERROR: Usage: $0 command_to_run args..." >&2
exit 1
}
enable_out_conn
trap exit_handler EXIT
echo "INFO: Running '$@' ..."
"$@"
echo "INFO: Done with exit code: $?"
exit 0
Example usage without and with script:
$ curl -fsS www.linux.cz
curl: (7) Failed to connect to www.linux.cz port 80: No route to host
$ sudo ./proxy_command.sh curl -fsS www.linux.cz
INFO: Enabling output connections: firewall-cmd --policy=out --add-service=https --add-service=httpsuccess
INFO: Running 'curl -fsS www.linux.cz' ...
...
output of HTML page
...
INFO: Done with exit code: 0
INFO: Disabling output connections: firewall-cmd --policy=out --remove-service=https --remove-service=httpsuccess
INFO: Policy 'out' on exit:
out (active)
priority: -1
target: REJECT
ingress-zones: HOST
egress-zones: ANY
services:
ports:
protocols:
masquerade: no
forward-ports:
source-ports:
icmp-blocks:
rich rules:
rule family="ipv4" destination address="192.168.122.1" service name="dns" accept
WordPress requires backend database - most popular is MySQL so we will stick with it:
sudo ./proxy_command.sh dnf install mysql-server
# enable and start MySQL in one step:
sudo systemctl enable --now mysqld
First generate safe password for MySQL root and store it somewhere:
pwgen 16 1 | tee ~/.mysql-root-pw.txt
chmod 600 ~/.mysql-root-pw.txt
But we are not yet done - we should secure mysql installation:
$ sudo mysql_secure_installation
Securing the MySQL server deployment.
Connecting to MySQL using a blank password.
VALIDATE PASSWORD COMPONENT can be used to test passwords
and improve security. It checks the strength of password
and allows the users to set only those passwords which are
secure enough. Would you like to setup VALIDATE PASSWORD component?
Press y|Y for Yes, any other key for No: n # I answered No for sake of simplicity
Please set the password for root here.
New password:
Re-enter new password:
By default, a MySQL installation has an anonymous user,
allowing anyone to log into MySQL without having to have
a user account created for them. This is intended only for
testing, and to make the installation go a bit smoother.
You should remove them before moving into a production
environment.
Remove anonymous users? (Press y|Y for Yes, any other key for No) : y
Success.
Normally, root should only be allowed to connect from
'localhost'. This ensures that someone cannot guess at
the root password from the network.
Disallow root login remotely? (Press y|Y for Yes, any other key for No) : y
Success.
By default, MySQL comes with a database named 'test' that
anyone can access. This is also intended only for testing,
and should be removed before moving into a production
environment.
Remove test database and access to it? (Press y|Y for Yes, any other key for No) : y
- Dropping test database...
Success.
You can try root login from localhost
using:
mysql -u root -h 127.0.0.1 -p mysql
mysql> quit
Last word mysql
on command line is name of database.
You can also create file with password, for example ~/.mysql-root.cnf
with contents:
[mysql]
user=root
password=YOUR_MYSL_ROOT_PASSWORD
And invoke client as: mysql --defaults-extra-file=$HOME/.mysql-root.cnf
We will install Apache (httpd
package) with https support (mod_ssl
package).
sudo ./proxy_command.sh dnf install httpd mod_ssl
Create simple index.html
to avoid default vendor page:
echo "<h1>Welcome"'!'"</h1>" | sudo tee /var/www/html/index.html
Note that exclamation mark (!
) has to be in single quotes!
There is prepared one-time service httpd-init.service
that will generate
self-signed certificate unless both /etc/pki/tls/certs/localhost.crt
and /etc/pki/tls/private/localhost.key
exists.
Before running Apache ensure that output of your
hostname
command is correct and is FQDN (fully qualified domain name)! For example:$ hostname alma9-wp.example.com
Only when your hostname
output is correct (FQDN) start Apache:
sudo systemctl start httpd
Now permanentnly enable http(s) connection from Trusted IP address using:
sudo firewall-cmd --permanent --zone=trusted-in --add-service=http --add-service=https
sudo firewall-cmd --reload
There should be not much risk form running simple Apache configuration. So we
temporarily enable unlimited http and https access (rule below applies to all
source addresses, except from addresses listed in trusted-in
zone):
sudo firewall-cmd --zone=public --add-service=http --add-service=https
Now you can try both http and https access:
$ curl -fsS alma9-wp.example.com
<h1>Welcome!</h1>
curl -kfsS https://alma9-wp.example.com
If you enabled Apache acces on public server you can watch in /var/log/httpd/
directory how long it will take before there will appear exploit attempts...
Ensure that your public
zone has not enabled http
os https
service:
$ sudo firewall-cmd --info-zone=public
public (active)
target: default
icmp-block-inversion: no
interfaces: eth0
sources:
services:
...
In my case services:
are empty so it should be OK.
Rationale is that unconfigured WordPress could be vulnerable.
Now we can install WordPress from EPEL using:
sudo ./proxy_command.sh dnf install wordpress
We can partially follow /usr/share/doc/wordpress/README.fedora
but please
note that MySQL 8 changed way how user password is defined (clause identified by ...
).
It can be no longer appended to grant ...
but only to create user
command.
Create script mysql_create_wp_user.sql
with contents:
create database wp;
create user wp identified by 'PASSWORD_OF_USER_WP';
grant all on wp.* to wp;
flush privileges;
And run it with prepared .cnf:
mysql --defaults-extra-file=$HOME/.mysql-root.cnf < mysql_create_wp_user.sql
Verify that user wp
can connect to database wp
via localhost
:
mysql -u wp -p -h 127.0.0.1 wp
mysql> quit
Now you have to edit /etc/wordpress/wp-config.php
and replace first:
diff -u wp-config.php.orig wp-config.php
--- wp-config.php.orig 2024-12-16 16:23:23.993000000 +0100
+++ wp-config.php 2024-12-16 16:34:53.588000000 +0100
@@ -20,16 +20,16 @@
// ** Database settings - You can get this info from your web host ** //
/** The name of the database for WordPress */
-define( 'DB_NAME', 'database_name_here' );
+define( 'DB_NAME', 'wp' );
/** Database username */
-define( 'DB_USER', 'username_here' );
+define( 'DB_USER', 'wp' );
/** Database password */
-define( 'DB_PASSWORD', 'password_here' );
+define( 'DB_PASSWORD', 'PASSWORD_OF_USER_WP' );
/** Database hostname */
-define( 'DB_HOST', 'localhost' );
+define( 'DB_HOST', '127.0.0.1' );
/** Database charset to use in creating database tables. */
define( 'DB_CHARSET', 'utf8' );
We must also generate several cookie keys - I created script generate_keys.sh
with contents:
#!/bin/bash
# Generate unique keys in wp-config.php
set -euo pipefail
f=/etc/wordpress/wp-config.php
from='put your unique phrase here'
for i in 'AUTH_KEY' 'SECURE_AUTH_KEY' 'LOGGED_IN_KEY' 'NONCE_KEY' \
'AUTH_SALT' 'SECURE_AUTH_SALT' 'LOGGED_IN_SALT' 'NONCE_SALT'
do
pw=`pwgen 64 1`
sed -i.bak -e "/'$i'/s@$from@$pw@" $f
done
exit 0
And run-it.
After run of script you verify that there is no longer phrase 'put your unique phrase here':
$ sudo fgrep 'put your unique phrase here' /etc/wordpress/wp-config.php
(no output)
Ensure that Apache will be started on boot:
sudo systemctl enable --now httpd
Now we can again follow /usr/share/doc/wordpress/README.fedora
and
in your browswer visit: https://FQDN/wordpress/wp-admin/install.php
You will likely get 403 Forbidden
error - if you are accessing it from
non-localhost computer (typical setup).
To fix it I have to replace require local
in /etc/httpd/conf.d/wordpress.conf
with: Require ip 192.168.122.1
and restart Apache
with sudo systemctl restart httpd
. And reload web page.
You will likely get Database access error. It is expected, because default
configuration of SELinux (it does not allow Apache to call connect(2)
. It is
no longer in kernel messages (dmesg
output will show nothing). However we
can follow
https://wiki.gentoo.org/wiki/SELinux/Tutorials/Where_to_find_SELinux_permission_denial_details
and try:
$ sudo ausearch -m avc --start recent
----
time->Mon Dec 16 16:46:58 2024
type=PROCTITLE msg=audit(1734364018.457:196): proctitle=7068702D66706D3A20706F6F6C20777777
type=SYSCALL msg=audit(1734364018.457:196): arch=c000003e syscall=42 success=no exit=-13 a0=5 a1=7f25a876b300 a2=10 a3=7ffdd616cf60 items=0 ppid=1465 pid=1645 auid=4294967295 uid=48 gid=48 euid=48 suid=48 fsuid=48 egid=48 sgid=48 fsgid=48 tty=(none) ses=4294967295 comm="php-fpm" exe="/usr/sbin/php-fpm" subj=system_u:system_r:httpd_t:s0 key=(null)
type=AVC msg=audit(1734364018.457:196): avc: denied { name_connect } for pid=1645 comm="php-fpm" dest=3306 scontext=system_u:system_r:httpd_t:s0 tcontext=system_u:object_r:mysqld_port_t:s0 tclass=tcp_socket permissive=0
So we have to change SELinux policy to allow Apache to connect to MySQL:
-
to find ho we have to install:
sudo ./proxy_command.sh dnf install selinux-policy-doc
-
next search and open right manual page:
$ whatis httpd_selinux httpd_selinux (8) - Security Enhanced Linux Policy for the httpd processes $ man httpd_selinux
-
there is right command to enable DB access from Apache:
sudo setsebool -P httpd_can_network_connect_db 1
-
now again reload WordPress install page (no restart needed). You should be greeted with installation Wizard...
-
just fill them with obvious values and click on
Install WordPress
-
there should be reported
Success!
and you can click to "Log In" in formhttps://FQDN/wordpress/wp-login.php
Please void tempation to make your WordPress installation public - there are too many risky settings...
As Admin go to General Settings:
- ensure that
Anyone can register
is NOT checked
In Writing:
- remove
http://rpc.pingomatic.com/
fromUpdate Services
Discussion:
- uncheck
Attempt to notify any blogs linked to from the post
- uncheck
Allow link notifications from other blogs (pingbacks and trackbacks) on new posts
- check
Users must be registered and logged in to comment
-
always check!!!
Comment must be manually approved
- it is CRITICAL - otherwise your site will be quickly misused as Content Farm (linking to infamous sites to boost their SEO score). - uncheck
Comment author must have a previously approved comment
- uncheck
Show Avatars
(even that can be misused)
We are still not done. We should limit access to plugins
and other pontentially dangerous
URLs...
There are still some attempts - where wordpress makes connections to outside:
sudo ausearch -m avc --start recent
type=PROCTITLE msg=audit(1734366395.333:296): proctitle=7068702D66706D3A20706F6F6C20777777
type=SYSCALL msg=audit(1734366395.333:296): arch=c000003e syscall=42 success=no exit=-13 a0=8 a1=7ffdd616d310 a2=10 a3=7ffdd61bd080 items=0 ppid=1465 pid=1647 auid=4294967295 uid=48 gid=48 euid=48 suid=48 fsuid=48 egid=48 sgid=48 fsgid=48 tty=(none) ses=4294967295 comm="php-fpm" exe="/usr/sbin/php-fpm" subj=system_u:system_r:httpd_t:s0 key=(null)
type=AVC msg=audit(1734366395.333:296): avc: denied { name_connect } for pid=1647 comm="php-fpm" dest=443 scontext=system_u:system_r:httpd_t:s0 tcontext=system_u:object_r:http_port_t:s0 tclass=tcp_socket permissive=0
- we can decode some entries to names using
-i
parameter, e.i.,sudo ausearch -m avc --start recent -i
type=PROCTITLE msg=audit(12/16/24 17:30:46.005:304) : proctitle=php-fpm: pool www
type=SYSCALL msg=audit(12/16/24 17:30:46.005:304) : arch=x86_64 syscall=connect success=no exit=EACCES(Permission denied) a0=0x8 a1=0x7ffdd616d310 a2=0x10 a3=0x7ffdd61bd080 items=0 ppid=1465 pid=1649 auid=unset uid=apache gid=apache euid=apache suid=apache fsuid=apache egid=apache sgid=apache fsgid=apache tty=(none) ses=unset comm=php-fpm exe=/usr/sbin/php-fpm subj=system_u:system_r:httpd_t:s0 key=(null)
type=AVC msg=audit(12/16/24 17:30:46.005:304) : avc: denied { name_connect } for pid=1649 comm=php-fpm dest=443 scontext=system_u:system_r:httpd_t:s0 tcontext=system_u:object_r:http_port_t:s0 tclass=tcp_socket permissive=0
-
we can see
syscall=connect success=no exit=EACCES(Permission denied) a0=0x8 a1=0x7ffdd616d310 a2=0x10 a3=0x7ffdd61bd080
-
the
aX
are syscall arguments (in this caseconnect(2)
arguments. -
infortunately target IP address is not passed as argument, but as pointer to
struct sockaddr
-
as trick we can install
tcpdump
and try:sudo tcpdump -n -p -i eth0 -v port 53
-
now we know what is likely target:
17:39:53.493737 IP (tos 0x0, ttl 64, id 35624, offset 0, flags [DF], proto UDP (17), length 63) 192.168.122.138.52050 > 192.168.122.1.domain: 14479+ A? api.wordpress.org. (35)
-
however this command reveals far too many possible calls to
api.wordpress.org
:$ fgrep -ri api.wordpress.org /usr/share/wordpress/ ... lot of results ...
-
please note that if WordPress connects directly to target IP address it will be not visible in above
tcpdump
session...
TODO: ...