<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://mimichatz.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://mimichatz.com/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-07-29T07:13:19+10:00</updated><id>https://mimichatz.com/feed.xml</id><title type="html">mimichatz</title><subtitle>Field notes on threat research, tools, detection engineering, and whatever else I feel like writing about.
</subtitle><author><name>mimichatz</name><email>vriesema73@gmail.com</email></author><entry><title type="html">Hardening linux for the homelab</title><link href="https://mimichatz.com/tooling/2026-07-29-hardening-scripts/" rel="alternate" type="text/html" title="Hardening linux for the homelab" /><published>2026-07-29T00:00:00+10:00</published><updated>2026-07-29T07:12:55+10:00</updated><id>https://mimichatz.com/tooling/hardening-scripts</id><content type="html" xml:base="https://mimichatz.com/tooling/2026-07-29-hardening-scripts/"><![CDATA[<ul id="markdown-toc">
  <li><a href="#the-part-everyone-gets-wrong-first-not-locking-yourself-out" id="markdown-toc-the-part-everyone-gets-wrong-first-not-locking-yourself-out">The part everyone gets wrong first: not locking yourself out</a></li>
  <li><a href="#logging-all-the-things-but-just-once" id="markdown-toc-logging-all-the-things-but-just-once">Logging all the things, but just once</a></li>
  <li><a href="#harden-lxcsh-the-same-policy-different-physics" id="markdown-toc-harden-lxcsh-the-same-policy-different-physics">harden-lxc.sh: the same policy, different physics</a></li>
  <li><a href="#audit-serversh-the-part-i-use-most" id="markdown-toc-audit-serversh-the-part-i-use-most">audit-server.sh: the part I use most</a></li>
  <li><a href="#the-same-auditor-against-a-proxmox-host" id="markdown-toc-the-same-auditor-against-a-proxmox-host">The same auditor against a Proxmox host</a></li>
  <li><a href="#harden-auditdsh-one-script-born-from-one-very-annoying-bug" id="markdown-toc-harden-auditdsh-one-script-born-from-one-very-annoying-bug">harden-auditd.sh: one script born from one very annoying bug</a></li>
  <li><a href="#things-i-would-tell-past-me" id="markdown-toc-things-i-would-tell-past-me">Things I would tell past me</a></li>
  <li><a href="#other-hardening-scripts-and-utilities-worth-your-time" id="markdown-toc-other-hardening-scripts-and-utilities-worth-your-time">Other hardening scripts and utilities worth your time</a></li>
  <li><a href="#wrapping-up" id="markdown-toc-wrapping-up">Wrapping up</a></li>
</ul>

<p>Every time I spin up a fresh VM or a Proxmox container, I end up doing the same
twenty minutes of clicking and typing. Create a user, drop in a key, move SSH
off port 22, turn on a firewall, install fail2ban, remember the auditd bit I
always forget.</p>

<p>It’s <em>boring</em>.
It’s easy to do something slightly differently each time,
and “slightly differently each time” is exactly how you end up with a fleet
where nobody (in this case me), knows what is actually configured.</p>

<p>So with the help of some claude (because I’m lazy), wrote it down as scripts and instructions. 
Four of them for now, which is a couple more than I planned.</p>

<p>This was also heavily inspired due to my recent foray into deploying Pangolin on a public VPS which is getting <strong>hammered</strong> by scanners and bruteforce.
So I felt this was a reassuring project to work on to give me peace of mind that I hadn’t made a balls of the config, and lets me audit it.</p>

<p>Also, just because these works for me, doesn’t mean it will for you. I honestly do not care if you use my scripts or not.
<a href="https://github.com/m1tcch/LinuxHardeningScripts">My hardening scripts</a> ,(subjectively), the best hardening scripts ever created lol.</p>

<p><strong>Still editing this a little but the post is mostly done</strong></p>

<table>
  <thead>
    <tr>
      <th>Script</th>
      <th>What it is for</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">harden-server.sh</code></td>
      <td>Full baseline for a VM or bare metal Debian/Ubuntu host</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">harden-lxc.sh</code></td>
      <td>Same idea, adapted to Proxmox LXC (Debian, Ubuntu, Alpine)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">harden-auditd.sh</code></td>
      <td>Just the auditd ruleset, for boxes you cannot fully re-baseline</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">audit-server.sh</code></td>
      <td>Checks the posture is still there, and tells you how to fix it</td>
    </tr>
  </tbody>
</table>

<p>All four are idempotent, so re-running them is safe. All four write a full
transcript to <code class="language-plaintext highlighter-rouge">/var/log/</code>, not just status lines, because when something goes
sideways at 23:00 you want the actual command output.</p>

<h2 id="the-part-everyone-gets-wrong-first-not-locking-yourself-out">The part everyone gets wrong first: not locking yourself out</h2>

<p>The single most likely way to break a server with a hardening script is to
disable password auth, move the SSH port, and restart sshd, all before checking
that your key actually works. Have been through the ringer on this where I then have to sort physical access or in some cases, nuke the OS and fresh install again. sigh.</p>

<p>Anyway, pubkey is the name of the game</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">[[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$PUBKEY</span><span class="s2">"</span> <span class="o">]]</span> <span class="o">||</span> die <span class="s2">"--pubkey or --pubkey-file is required (prevents SSH lockout)"</span>
<span class="nb">echo</span> <span class="s2">"</span><span class="nv">$PUBKEY</span><span class="s2">"</span> | <span class="nb">grep</span> <span class="nt">-qE</span> <span class="s1">'^(ssh-(rsa|ed25519)|ecdsa-sha2) '</span> <span class="se">\</span>
    <span class="o">||</span> die <span class="s2">"supplied key does not look like an OpenSSH public key"</span>
</code></pre></div></div>

<p>The script flatly refuses to run without a key, and it sanity checks that what
you handed it looks like an OpenSSH public key rather than, say, a private key
or a path you meant to pass to <code class="language-plaintext highlighter-rouge">--pubkey-file</code>.</p>

<p>The same idea shows up right before sshd is restarted:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sshd <span class="nt">-t</span> <span class="o">||</span> die <span class="s2">"sshd config validation failed - NOT restarting sshd"</span>
systemctl restart ssh 2&gt;/dev/null <span class="o">||</span> systemctl restart sshd
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">sshd -t</code> parses the effective config. If the drop-in is malformed, the script
dies with the old daemon still running and your session still alive. Same
pattern for sudo, where a broken sudoers file is just as fatal:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$NEW_USER</span><span class="s2"> ALL=(ALL:ALL) NOPASSWD:ALL"</span> <span class="o">&gt;</span> <span class="s2">"/etc/sudoers.d/90-</span><span class="nv">$NEW_USER</span><span class="s2">"</span>
<span class="nb">chmod </span>440 <span class="s2">"/etc/sudoers.d/90-</span><span class="nv">$NEW_USER</span><span class="s2">"</span>
visudo <span class="nt">-cf</span> <span class="s2">"/etc/sudoers.d/90-</span><span class="nv">$NEW_USER</span><span class="s2">"</span> <span class="o">&gt;</span>/dev/null <span class="se">\</span>
    <span class="o">||</span> die <span class="s2">"sudoers drop-in failed validation"</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">NOPASSWD</code> looks alarming until you remember the account has no password at
all. It is key only login, exactly like the <code class="language-plaintext highlighter-rouge">ubuntu</code> or <code class="language-plaintext highlighter-rouge">admin</code> user on a cloud
image. Without <code class="language-plaintext highlighter-rouge">NOPASSWD</code> you would have a sudo group member who can never
actually run sudo (oops, I’ve definitely not done that before).</p>

<h2 id="logging-all-the-things-but-just-once">Logging all the things, but just once</h2>

<p>This one small trick that “yolo” sysadmins hate:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">touch</span> <span class="s2">"</span><span class="nv">$LOG_FILE</span><span class="s2">"</span> <span class="o">&amp;&amp;</span> <span class="nb">chmod </span>600 <span class="s2">"</span><span class="nv">$LOG_FILE</span><span class="s2">"</span>
<span class="nb">exec</span> <span class="o">&gt;</span> <span class="o">&gt;(</span><span class="nb">tee</span> <span class="nt">-a</span> <span class="s2">"</span><span class="nv">$LOG_FILE</span><span class="s2">"</span><span class="o">)</span> 2&gt;&amp;1
</code></pre></div></div>

<p>From that line onward, every command’s stdout and stderr goes to both your
terminal and the log. No per command redirection, no forgetting one. The mode
600 matters because apt output and sshd config dumps are not something you want
world readable. This is pleasant when I’m doing an overdue patch or upgrade and I want to see what the hell ended up on the system.</p>

<p>The other readability helper is a two line function that numbers the phases:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>section<span class="o">()</span> <span class="o">{</span> <span class="nv">STEP</span><span class="o">=</span><span class="k">$((</span>STEP <span class="o">+</span> <span class="m">1</span><span class="k">))</span><span class="p">;</span> log <span class="s2">""</span><span class="p">;</span> log <span class="s2">"===== [</span><span class="nv">$STEP</span><span class="s2">] </span><span class="nv">$*</span><span class="s2"> ====="</span><span class="p">;</span> <span class="o">}</span>
</code></pre></div></div>

<p>That is what produces the <code class="language-plaintext highlighter-rouge">===== [4] Hardening sshd =====</code> markers you see in
the screenshots below. When someone sends you a 4000 line transcript, those
markers are how you find the failure in ten seconds instead of ten minutes.</p>

<h2 id="harden-lxcsh-the-same-policy-different-physics">harden-lxc.sh: the same policy, different physics</h2>

<p>Proxmox containers are where this got interesting. An unprivileged LXC cannot
own the kernel, so about a third of the baseline simply does not apply. And
Alpine, which is a very popular LXC template, has no bash at all.</p>

<p>That second constraint drove the whole design: <code class="language-plaintext highlighter-rouge">harden-lxc.sh</code> is POSIX sh, so
it runs under dash and under busybox ash.</p>

<p><img src="/assets/img/blog/hardening/image5.png" alt="Alpine test container in the Proxmox UI" /></p>

<p><em>A stock unprivileged Alpine container, 512 MiB, freshly booted. This is the
harder target, so it is the one worth testing against.</em></p>

<p><img src="/assets/img/blog/hardening/image1.png" alt="Alpine os-release and the script header" /></p>

<p><em><code class="language-plaintext highlighter-rouge">#!/bin/sh</code>, not <code class="language-plaintext highlighter-rouge">#!/bin/bash</code>. Alpine 3.24 does not have bash unless you
install it, and asking people to install bash before running a hardening script
is a bit rude.</em></p>

<p>On the Debian side it is the familiar path. Here it is on a Debian 13 (trixie)
container, started with the same flags you would use on a VM:</p>

<p><img src="/assets/img/blog/hardening/image7.png" alt="Starting harden-lxc.sh on Debian 13" /></p>

<p><em>The banner echoes back every setting it parsed, then tells you where the full
transcript lives before it does anything else.</em></p>

<p>The middle of the run is where the container specific behaviour shows up:</p>

<p><img src="/assets/img/blog/hardening/image4.png" alt="Steps 4 through 9 of the LXC run" /></p>

<p>Three things worth pointing out in that screenshot.</p>

<p><strong><code class="language-plaintext highlighter-rouge">ufw limit</code> rather than <code class="language-plaintext highlighter-rouge">ufw allow</code>.</strong> Rate limiting the SSH port means an
address making six or more connections in thirty seconds gets dropped by the
firewall itself, before sshd or fail2ban ever sees it.</p>

<p><strong>“sysctl: 12 applied, 0 skipped (read-only in this container)”.</strong> Namespaced
<code class="language-plaintext highlighter-rouge">net.*</code> keys can be set inside an LXC. Kernel global <code class="language-plaintext highlighter-rouge">kernel.*</code>, <code class="language-plaintext highlighter-rouge">fs.*</code> and
<code class="language-plaintext highlighter-rouge">vm.*</code> keys cannot, and trying to set them just errors out. The script probes
each key, applies what it can, counts what it could not, and prints the reason
instead of pretending it succeeded.</p>

<p><strong>The permissions block.</strong> <code class="language-plaintext highlighter-rouge">/etc/shadow</code> and <code class="language-plaintext highlighter-rouge">/etc/gshadow</code> at <code class="language-plaintext highlighter-rouge">640 root:shadow</code>
follows the CIS Debian benchmark. The shadow group is empty by default, so in
practice it is root only, but it matches what a benchmark scan expects to find.
That is handled by a small helper so every file goes through the same code path:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>harden_perm<span class="o">()</span> <span class="o">{</span>  <span class="c"># harden_perm &lt;path&gt; &lt;mode&gt; &lt;owner:group&gt;</span>
</code></pre></div></div>

<p>The firewall is the one place where a container genuinely might not cooperate.
On Alpine there is no ufw, so the script generates an nftables ruleset instead,
validates it, and only then loads it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if </span>nft <span class="nt">-c</span> <span class="nt">-f</span> /etc/nftables.nft <span class="o">&amp;&amp;</span> nft <span class="nt">-f</span> /etc/nftables.nft<span class="p">;</span> <span class="k">then
    </span>svc_enable nftables
    <span class="nv">FIREWALL_OK</span><span class="o">=</span>1
</code></pre></div></div>

<p>And if the firewall cannot be enabled at all, which is common in unprivileged
containers missing <code class="language-plaintext highlighter-rouge">NET_ADMIN</code>, the script does not fail. It says so plainly and
points you at the layer that can do the job:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>WARNING: in-container firewall could not be enabled (common in
         unprivileged LXCs without the needed capabilities).
         Use the Proxmox firewall for this container instead
</code></pre></div></div>

<p>That felt like the right call after a not so heated debate with claude later. 
A hardening script that dies two thirds of the way through leaves you worse off than one that finishes and tells you what it
could not cover.</p>

<p>Which brings us to the ending:</p>

<p><img src="/assets/img/blog/hardening/image9.png" alt="The finished LXC run and a verification login" /></p>

<p><em>Left: the closing summary, including the explicit checklist of things that are
the Proxmox host’s job (auditd, AppArmor, kernel sysctls, swap limits, time
sync, backups). Right: the actual point of the exercise, logging in as <code class="language-plaintext highlighter-rouge">deploy</code>
on port 2222 with a key, from a second terminal, before closing the first one.</em></p>

<p><strong>Please</strong> actually do that second terminal step. The script cannot do it for you.</p>

<h2 id="audit-serversh-the-part-i-use-most">audit-server.sh: the part I use most</h2>

<p>Hardening is a one time event. Drift is forever. Somebody installs a package
that ships its own sysctl file, a config gets replaced by an upgrade, I randomly open a port “temporarily” in March. Threat actor gets in and wrecks the host. etc etc.
 So the auditor is the script I
actually run on a schedule.</p>

<p>It is read only by default, and it reads effective state rather than config
files. SSH policy comes from <code class="language-plaintext highlighter-rouge">sshd -T</code> output, not from grepping
<code class="language-plaintext highlighter-rouge">sshd_config</code>, because a drop-in you forgot about can override anything. 
This actually happened to me where my VPS provider had their own drop in that overrided a lot of my good SSHD work.
<a href="https://support.binarylane.com.au/support/solutions/articles/11000135607-why-your-ssh-hardening-changes-aren-t-working-on-binarylane">Binary Lane SSHD Malarkey</a> I don’t blame the VPS provider at all for this. 
But it’s worth checking what sort of lower number confs might be taking priority over any changes you’ve got. The audit script will surface if anything isn’t sticking.</p>

<p><img src="/assets/img/blog/hardening/image13.png" alt="Audit output on the hardened container" /></p>

<p>Notice the <code class="language-plaintext highlighter-rouge">[INFO]</code> lines. Inside a container the audit does not report auditd,
chrony, swap, AppArmor and kernel sysctls as failures. It detects the
environment once at startup:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">IS_CONTAINER</span><span class="o">=</span>0
systemd-detect-virt <span class="nt">--container</span> <span class="nt">--quiet</span> 2&gt;/dev/null <span class="o">&amp;&amp;</span> <span class="nv">IS_CONTAINER</span><span class="o">=</span>1

<span class="nv">IS_PVE</span><span class="o">=</span>0
<span class="o">[[</span> <span class="nt">-d</span> /etc/pve <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="nv">IS_PVE</span><span class="o">=</span>1
</code></pre></div></div>

<p>and then skips the subsystems the host owns. A red FAIL you are supposed to
ignore is worse than no check at all, because after the third run nobody reads
the output.</p>

<p>Every non passing check carries its own remediation, recorded at the point the
check runs:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>warn<span class="o">()</span> <span class="o">{</span> <span class="nv">WARN</span><span class="o">=</span><span class="k">$((</span>WARN+1<span class="k">))</span><span class="p">;</span> <span class="nb">printf</span> <span class="s1">'  [WARN] %s\n'</span> <span class="s2">"</span><span class="nv">$1</span><span class="s2">"</span><span class="p">;</span> record WARN <span class="s2">"</span><span class="nv">$1</span><span class="s2">"</span> <span class="s2">"</span><span class="k">${</span><span class="nv">2</span><span class="p">-</span><span class="k">}</span><span class="s2">"</span> <span class="s2">"</span><span class="k">${</span><span class="nv">3</span><span class="p">-</span><span class="k">}</span><span class="s2">"</span><span class="p">;</span> <span class="o">}</span>
bad<span class="o">()</span>  <span class="o">{</span> <span class="nv">FAIL</span><span class="o">=</span><span class="k">$((</span>FAIL+1<span class="k">))</span><span class="p">;</span> <span class="nb">printf</span> <span class="s1">'  [FAIL] %s\n'</span> <span class="s2">"</span><span class="nv">$1</span><span class="s2">"</span><span class="p">;</span> record FAIL <span class="s2">"</span><span class="nv">$1</span><span class="s2">"</span> <span class="s2">"</span><span class="k">${</span><span class="nv">2</span><span class="p">-</span><span class="k">}</span><span class="s2">"</span> <span class="s2">"</span><span class="k">${</span><span class="nv">3</span><span class="p">-</span><span class="k">}</span><span class="s2">"</span><span class="p">;</span> <span class="o">}</span>
</code></pre></div></div>

<p>The third argument to <code class="language-plaintext highlighter-rouge">record</code> is a safety class, and it is the core idea of the
whole script:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">[auto]</code> means idempotent, cannot cost you remote access, no reboot. Think
<code class="language-plaintext highlighter-rouge">chmod 600</code> on sshd_config, a missing UFW rule for the port you are already
connected on, a sysctl value, starting a service that is installed but
stopped.</li>
  <li><code class="language-plaintext highlighter-rouge">[manual]</code> is everything else. Anything touching SSH config, firewall
defaults, key material, the kernel command line, package upgrades, or that
needs a human to make a call.</li>
</ul>

<p>Default is <code class="language-plaintext highlighter-rouge">manual</code>. When in doubt, manual.</p>

<p><img src="/assets/img/blog/hardening/image6.png" alt="Patch status, Lynis findings and the start of the remediation guidance" /></p>

<p><em>Lynis gets itemised rather than reduced to a score. Every warning gets its test
ID, the finding text and a concrete fix. Suggestions are IDs only unless you ask
for <code class="language-plaintext highlighter-rouge">--lynis-details</code>, because 41 suggestions of full text is a wall.</em></p>

<p>On the hardening index, since people fixate on it: 75/100 is fine. It is a ratio
of tests passed to tests attempted, not a grade. A host that deliberately
declines suggestions, keeps USB storage enabled for backups and keeps SSH
forwarding for tunnels, will sit in the seventies and be correct. Fix warnings
first, treat suggestions as a menu.</p>

<p><img src="/assets/img/blog/hardening/image8.png" alt="Audit summary line" /></p>

<p><em>The summary is also the exit code. Zero failures gives exit 0, one or more
gives exit 1, which is what makes it cron and monitoring friendly.</em></p>

<h2 id="the-same-auditor-against-a-proxmox-host">The same auditor against a Proxmox host</h2>

<p>Here is where it gets more useful, because this is a real host that was never
run through <code class="language-plaintext highlighter-rouge">harden-server.sh</code>. It has been configured by hand over time.</p>

<p><img src="/assets/img/blog/hardening/image2.png" alt="Audit on the Proxmox VE host" /></p>

<p><em>“Proxmox VE host detected, bridge-friendly exceptions applied.” On a PVE node
the built in <code class="language-plaintext highlighter-rouge">pve-firewall</code> is the natural choice, so a missing ufw is reported
but with context rather than a scolding.</em></p>

<p><img src="/assets/img/blog/hardening/image11.png" alt="Continued audit output on the PVE host" /></p>

<p>Four real findings on that box: no auditd, <code class="language-plaintext highlighter-rouge">accept_redirects</code> left on,
<code class="language-plaintext highlighter-rouge">sshd_config</code> sitting at world readable 644, and nine pending security updates.
That last one is the argument for running this on a schedule in one line.</p>

<p>The remediation section is where I put the most effort, because “FAIL: swappiness
is 60” is useless on its own. Every finding gets the reason it matters, the exact
commands, and the trade-off if the current state was deliberate:</p>

<p><img src="/assets/img/blog/hardening/image10.png" alt="Remediation guidance, findings 2 through 5" /></p>

<p><em>The <code class="language-plaintext highlighter-rouge">rp_filter</code> entry is my favourite one to have gotten right. On a Proxmox
host, loose mode is often necessary for asymmetric guest routing, so the fix
text says “KEEP 2 if any guest uses asymmetric routing” and tells you to verify
connectivity before you log out. The baseline wants 1. Sometimes the baseline is
wrong for your box.</em></p>

<p><img src="/assets/img/blog/hardening/image14.png" alt="Remediation guidance, findings 6 through 8, and the summary" /></p>

<p>Note the auditd entry says installing is out of scope for the auditor. That is
not laziness, it is a rule enforced in code. Even if I misclassify a check as
<code class="language-plaintext highlighter-rouge">auto</code>, this backstop refuses it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>is_install_cmd<span class="o">()</span> <span class="o">{</span>
    <span class="o">[[</span> <span class="s2">"</span><span class="nv">$1</span><span class="s2">"</span> <span class="o">=</span>~ <span class="o">(</span>apt|apt-get|aptitude|dpkg|yum|dnf|zypper|pacman|apk|snap|pip3?|npm|cargo|gem|go<span class="o">)[[</span>:space:]]+<span class="o">(</span>-[^[:space:]]+[[:space:]]+<span class="o">)</span><span class="k">*</span><span class="o">(</span><span class="nb">install</span>|add|-i|--install<span class="o">)</span> <span class="o">]]</span> <span class="se">\</span>
        <span class="o">||</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$1</span><span class="s2">"</span> <span class="o">=</span>~ <span class="o">(</span>curl|wget<span class="o">)[[</span>:space:]].<span class="k">*</span><span class="se">\|</span><span class="o">[[</span>:space:]]<span class="k">*</span><span class="o">(</span><span class="nb">sudo</span><span class="o">[[</span>:space:]]+<span class="o">)</span>?<span class="o">(</span>ba<span class="o">)</span>?sh <span class="o">]]</span>
<span class="o">}</span>
</code></pre></div></div>

<p>A finding containing an install command is skipped whole, never half applied. An
auditor that quietly installs software is not an auditor any more.</p>

<p>With that in place, <code class="language-plaintext highlighter-rouge">--remediate</code> is safe to actually use:</p>

<p><img src="/assets/img/blog/hardening/image3.png" alt="Applying the auto classified fixes" /></p>

<p><em>Three auto fixes applied, five manual findings untouched, five second abort
window before it starts, and each command echoed with its result. Then it tells
you to re-run without <code class="language-plaintext highlighter-rouge">--remediate</code> to confirm the new state, which is the only
verification that counts.</em></p>

<p>There is also <code class="language-plaintext highlighter-rouge">--dry-run</code> if you want to see the plan first, and <code class="language-plaintext highlighter-rouge">--fix-script</code>
if you would rather get a mode 700 script to read through and run yourself.</p>

<h2 id="harden-auditdsh-one-script-born-from-one-very-annoying-bug">harden-auditd.sh: one script born from one very annoying bug</h2>

<p>This one exists because of a trap that cost me an afternoon.</p>

<p><code class="language-plaintext highlighter-rouge">augenrules --load</code> only reloads when its generated output differs from
<code class="language-plaintext highlighter-rouge">/etc/audit/audit.rules</code>. On a stock Debian box, <code class="language-plaintext highlighter-rouge">rules.d/audit.rules</code> contains
only <code class="language-plaintext highlighter-rouge">-D</code>, <code class="language-plaintext highlighter-rouge">-b</code> and <code class="language-plaintext highlighter-rouge">-f</code>. So you drop in your rules, run <code class="language-plaintext highlighter-rouge">augenrules --load</code>, it
prints <code class="language-plaintext highlighter-rouge">No change</code>, exits 0, and you have exactly zero rules loaded. Exit code
zero. Everything looks fine. Nothing is being audited. <em>What a pain in the hole</em>.</p>

<p>So the script never trusts the loader. It asks the kernel:</p>

<p>EXCUSE ME, JOHN KERNEL, ANY RULES GOING???</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">LOADED</span><span class="o">=</span><span class="si">$(</span>auditctl <span class="nt">-l</span> 2&gt;/dev/null | <span class="nb">grep</span> <span class="nt">-cve</span> <span class="s1">'^No rules'</span> <span class="o">||</span> <span class="nb">true</span><span class="si">)</span>
<span class="k">if</span> <span class="o">((</span> LOADED <span class="o">==</span> 0 <span class="o">))</span> <span class="o">&amp;&amp;</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$AUDIT_ENABLED</span><span class="s2">"</span> <span class="o">!=</span> <span class="s2">"2"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
    </span>log <span class="s2">"augenrules loaded nothing — falling back to 'auditctl -R'"</span>
    auditctl <span class="nt">-R</span> /etc/audit/audit.rules 2&gt;&amp;1 | <span class="nb">tail</span> <span class="nt">-5</span> <span class="o">||</span> <span class="nb">true
    </span><span class="nv">LOADED</span><span class="o">=</span><span class="si">$(</span>auditctl <span class="nt">-l</span> 2&gt;/dev/null | <span class="nb">grep</span> <span class="nt">-cve</span> <span class="s1">'^No rules'</span> <span class="o">||</span> <span class="nb">true</span><span class="si">)</span>
<span class="k">fi</span>
</code></pre></div></div>

<p>Exit code 0 from this script means rules were verified present in the kernel,
not merely written to disk.</p>

<p>Two other things it handles. It emits modern
<code class="language-plaintext highlighter-rouge">-a always,exit -F path=... -F perm=... -F key=...</code> syntax rather than legacy
<code class="language-plaintext highlighter-rouge">-w</code> watches, which auditd 3.1 and later warns about once per rule. And it skips
rules naming paths the host does not have, because a rule referencing
<code class="language-plaintext highlighter-rouge">/etc/crowdsec</code> on a box without CrowdSec is rejected by the kernel and aborts
the rest of the file. Silently. Taking your remaining rules with it.</p>

<p><img src="/assets/img/blog/hardening/image12.png" alt="harden-auditd.sh on the Proxmox host" /></p>

<p><em>“skipped 2 rule(s) for paths not on this host”, then 23 rules written, then
“rules loaded in the kernel: 23 (file defines 23)”. The counts matching is the
whole point. It finishes by printing the <code class="language-plaintext highlighter-rouge">ausearch</code> and <code class="language-plaintext highlighter-rouge">aureport</code> commands for
reading what it collects, because collecting audit logs nobody knows how to
query is just disk usage.</em></p>

<p>It also raises retention to 5 x 50 MB. Debian’s stock 5 x 8 MB is a few hours on
a busy host, which is not much use when you are reconstructing something that
happened on Tuesday.</p>

<h2 id="things-i-would-tell-past-me">Things I would tell past me</h2>

<p><span style="font-size: 26px;"><strong>Docker bypasses UFW.</strong></span> The amount of times I forget this.
Published ports go straight into iptables, below your
ufw rules. Bind to loopback or use the <code class="language-plaintext highlighter-rouge">DOCKER-USER</code> chain. The auditor flags
containers publishing on all interfaces, which is how I generally find out or remember the way this behaves.</p>

<p><strong><code class="language-plaintext highlighter-rouge">AllowTcpForwarding no</code> breaks your tunnels.</strong> It is in the baseline because
it reduces attack surface. If you rely on <code class="language-plaintext highlighter-rouge">ssh -L</code>, flip it and know why. Sometimes I love a good tunnel</p>

<p><strong>Fewer checks that people read beats more checks that people skip.</strong> Most of
the work in the audit script is not the checking, it is deciding what counts as
a failure on this particular kind of host.</p>

<p><strong>A dry run mode is not optional.</strong> <code class="language-plaintext highlighter-rouge">--dry-run</code> and <code class="language-plaintext highlighter-rouge">--fix-script</code> get used far
more than <code class="language-plaintext highlighter-rouge">--remediate</code> does, and that is the correct ratio. I honestly end up doing a lot of the remediation manually based on the scripts feedback anyways, because I don’t trust like that.</p>

<h2 id="other-hardening-scripts-and-utilities-worth-your-time">Other hardening scripts and utilities worth your time</h2>

<p>I did not invent any of this, and depending on your situation one of these may
suit you better than a shell script. Just because it works for me, doesn’t mean it will for you. I honestly do not care if you use my scripts or not.</p>

<p>**Other hardening scripts, because the wheel MUST be reinvented (as my wheel is better)</p>

<p><a href="https://github.com/captainzero93/security_harden_linux">security_harden_linux</a> captainzero’s might hardening scripts
<a href="https://medium.com/devsecops-ai/10-linux-hardening-scripts-every-devsecops-engineer-can-use-264e510de5a9">medium article with hardening bits</a> medium devsecops post with little hardening nuggets</p>

<p><strong>Frameworks and benchmarks</strong></p>

<ul>
  <li><a href="https://www.cisecurity.org/cis-benchmarks">CIS Benchmarks</a> are the reference
most of the above is derived from. Free PDFs after registration.</li>
  <li><a href="https://github.com/ComplianceAsCode/content">ComplianceAsCode / OpenSCAP content</a>
gives you machine readable CIS, STIG and PCI profiles plus generated
remediation. The serious option if you have to prove compliance.</li>
  <li><a href="https://www.open-scap.org/">OpenSCAP</a> is the scanner that consumes those.</li>
  <li><a href="https://ubuntu.com/security/certifications/docs/usg">Ubuntu Security Guide (usg)</a>
is Canonical’s CIS and DISA STIG tooling, bundled with Ubuntu Pro.</li>
</ul>

<p><strong>Config management, if you have more than a few boxes</strong></p>

<ul>
  <li><a href="https://github.com/dev-sec/ansible-collection-hardening">dev-sec.io hardening collection</a>
covers os, ssh, nginx, mysql and more as Ansible roles. This is where I would
go the moment a shell script stops scaling.</li>
  <li><a href="https://github.com/konstruktoid/hardening">konstruktoid/hardening</a> is a very
thorough Ubuntu focused script and Ansible role, with clear reasoning per
control.</li>
</ul>

<p><strong>Auditing and scanning</strong></p>

<ul>
  <li><a href="https://cisofy.com/lynis/">Lynis</a>, used by these scripts, is the best broad
first pass on a single host.</li>
  <li><a href="https://github.com/jtesta/ssh-audit">ssh-audit</a> grades your actual SSH
key exchange, ciphers and MACs from the client side. Pairs well with
<a href="https://infosec.mozilla.org/guidelines/openssh">Mozilla’s OpenSSH guidelines</a>.</li>
  <li><a href="https://github.com/docker/docker-bench-security">docker-bench-security</a> is
the CIS Docker benchmark as a script.</li>
  <li><a href="https://github.com/a13xp0p0v/kernel-hardening-checker">kernel-hardening-checker</a>
checks your kernel config and boot parameters against known hardening options.</li>
  <li><a href="https://github.com/aquasecurity/trivy">Trivy</a> for image, filesystem and
config scanning.</li>
  <li><code class="language-plaintext highlighter-rouge">systemd-analyze security</code> is already on your box and will rank every unit’s
sandboxing. Try it on something you wrote.</li>
</ul>

<p><strong>Runtime defence</strong></p>

<ul>
  <li><a href="https://github.com/fail2ban/fail2ban">fail2ban</a>, the classic log parser and
banner.</li>
  <li><a href="https://www.crowdsec.net/">CrowdSec</a>, same idea plus a shared reputation
feed. <code class="language-plaintext highlighter-rouge">harden-server.sh</code> can install it with <code class="language-plaintext highlighter-rouge">--crowdsec</code>.</li>
  <li><a href="https://github.com/Neo23x0/auditd">Linux Audit’s auditd rule sets</a>, a good
reference if you want a broader ruleset than the baseline here.</li>
  <li><a href="https://aide.github.io/">AIDE</a> for filesystem integrity, if you want to know
when a binary changes.</li>
</ul>

<p><strong>Reading</strong></p>

<ul>
  <li><a href="https://www.debian.org/doc/manuals/securing-debian-manual/">Debian securing manual</a></li>
  <li><a href="https://wiki.archlinux.org/title/Security">Arch Wiki: Security</a>, which is
excellent regardless of distro</li>
  <li><a href="https://pve.proxmox.com/wiki/Firewall">Proxmox VE firewall docs</a>, essential
if you are running containers</li>
</ul>

<h2 id="wrapping-up">Wrapping up</h2>

<p>The scripts are not clever. They are the same twenty minutes of setup I was
doing by hand, written down once, plus an auditor that tells me when reality has
drifted from the plan. The auditor turned out to be the valuable half.</p>

<p>If you take one idea from this, take the safety classification: separate the
fixes that cannot possibly hurt you from the ones that need a human, default to
the second group, and enforce the boundary in code rather than in a comment. It
is what makes automated remediation something you can leave switched on.</p>

<p>Test on a throwaway VM first. Keep a second terminal open. You know the drill.</p>]]></content><author><name>mimichatz</name><email>vriesema73@gmail.com</email></author><category term="tooling" /><category term="linux" /><category term="proxmox" /><category term="debian" /><category term="alpine" /><category term="auditd" /><category term="hardening" /><summary type="html"><![CDATA[Four shell scripts that baseline a Linux host or a Proxmox container, plus an auditor that proves the posture is still there weeks later.]]></summary></entry><entry><title type="html">Welcome to mimichatz</title><link href="https://mimichatz.com/2026-07-20-welcome-to-mimichatz/" rel="alternate" type="text/html" title="Welcome to mimichatz" /><published>2026-07-20T00:00:00+10:00</published><updated>2026-07-29T07:12:55+10:00</updated><id>https://mimichatz.com/welcome-to-mimichatz</id><content type="html" xml:base="https://mimichatz.com/2026-07-20-welcome-to-mimichatz/"><![CDATA[<p>Welcome to <strong>mimichatz</strong> - This will be a blog documenting threat research, interesting
projects, and security tooling.</p>

<p>I made this as a little portfolio for some interesting things I’ve been working on in my personal and professional life.</p>

<p>Research that never gets written up might as well not have happened. This site
is the forcing function: if it was worth investigating, it’s worth documenting at least for me.</p>

<p>Another thing that I’m shocking at is kicking off projects that solve problems, at least for myself, and then stopping them or keeping them secret. 
These typically never get shared because they work for me, and that stops others from checking them out or improving them.
Once again, this will hopefully let me put all my projects and tools in one place, be they bad, good, or indifferent.</p>

<p>Expect posts on:</p>
<ul>
  <li>Threat research and TTP deets</li>
  <li>Tools — built, broken, or reviewed</li>
  <li>Lab and homelab projects</li>
  <li>Literally anything else I feel like writing about</li>
</ul>]]></content><author><name>mimichatz</name><email>vriesema73@gmail.com</email></author><category term="introductions &amp; pleasantries" /><summary type="html"><![CDATA[What this blog is, what it covers, and why it exists.]]></summary></entry></feed>