AlmaLinuxHardenedWP - hpaluch/hpaluch.github.io GitHub Wiki

Hardening AlmaLinux 9 for WordPress

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.

SELinux should be enabled

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

Enabling EPEL

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.

RSyslog

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

Firewalld (part 1)

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

Restrict SSH access - sshd

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.

Firewalld (part 2) - restrict remote SSH access

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

Firewalld (part 3) - restrict outgoing connections

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

Installing MySQL server

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

Installing Apache Web Server

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

Installing WordPress

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 form https://FQDN/wordpress/wp-login.php

Please void tempation to make your WordPress installation public - there are too many risky settings...

Securing WordPress

As Admin go to General Settings:

  • ensure that Anyone can register is NOT checked

In Writing:

  • remove http://rpc.pingomatic.com/ from Update 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 case connect(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: ...

⚠️ **GitHub.com Fallback** ⚠️