DEV Community

Cover image for Remediating 18 OpenSSL CVEs at Scale with Puppet
Jason St-Cyr for puppet

Posted on

Remediating 18 OpenSSL CVEs at Scale with Puppet

Written by Paul Reed.

The June 2026 OpenSSL advisory is a big one. 18 vulnerabilities, one rated high severity with remote code execution potential, and a disclosure credited in part to Anthropic's Mythos model working alongside researcher Alex Gaynor. Six of those CVEs trace back to that collaboration.

If you manage infrastructure at scale, here is how to scope your exposure and get patched cleanly.

The Vulnerability: What CVE-2026-45447 Actually Does

CVE-2026-45447 is a heap use-after-free in PKCS7_verify(). The bug fires when OpenSSL processes a PKCS#7 or S/MIME signed message where the SignedData.digestAlgorithms field is an empty ASN.1 SET.

When that happens, OpenSSL frees a BIO object that was passed in by the calling application and is still expected to be valid. The calling application then uses the freed pointer. Depending on heap layout, that results in heap corruption, a process crash, or with a controlled heap grooming primitive, code execution.

Affected ranges:

  • OpenSSL 3.0.x through 3.3.x (patch to 3.5.1)
  • OpenSSL 1.1.1x (patch to corresponding 1.1.1 update)

The other 17 CVEs in the advisory cover authentication bypass via forged certificates (moderate, ~1-in-256 success rate), ciphertext forgery, private key recovery, root CA replacement, and several DoS vectors. None are trivial in regulated environments.

Step 1: Query Your Actual Exposure

Before touching anything, you want a precise list of affected nodes. PuppetDB gives you this directly from agent-reported facts. No scanner, no spreadsheet, no guessing.

puppet query 'inventory[certname, facts.os.name, facts.os.release.full, facts.openssl_version] {
  facts.openssl_version ~ "^(3\.[0-3]\.|1\.[0-1]\.)" and
  facts.openssl_version != absent
}'
Enter fullscreen mode Exit fullscreen mode

This returns every managed node reporting a vulnerable OpenSSL version, along with OS name, release, and the installed version string. Pipe it to jq for filtering:

puppet query 'inventory[certname, facts.openssl_version, facts.environment] { 

  facts.openssl_version ~ "^(3\.[0-3]\.|1\.[0-1]\.)" 

}' | jq '.[] | select(.facts.environment == "production")' 
Enter fullscreen mode Exit fullscreen mode

You now have your production blast radius in seconds.

Step 2: Patch It

There are two approaches depending on what you need: an immediate one-time push, or ongoing enforced state.

Option A: Run the Package Task (Puppet Enterprise Advanced)

For an immediate patch across a set of nodes, the built-in package task gets it done without any code changes:

puppet task run package action=upgrade name=openssl --nodes node1,node2,node3 
Enter fullscreen mode Exit fullscreen mode

For scale, run the task through the Puppet Enterprise GUI and target an entire node group at once. Concurrency limits apply, so large fleets roll out in controlled waves automatically. This is the fastest path when you need to push now and sort out the code side later.

Option B: Enforce with the Package Resource (Puppet Core and Puppet Enterprise)

For ongoing enforcement through Puppet code, a simple package resource is all you need. No additional module required.

package { 'openssl': 
  ensure => latest, # or pin to the specific version: '3.5.1' 
} 
Enter fullscreen mode Exit fullscreen mode

If you want to lock to the exact patched version rather than latest — which is the safer choice for production since it keeps change controlled — set the version string directly:

package { 'openssl': 
  ensure => '3.5.1', 
}
Enter fullscreen mode Exit fullscreen mode

Add this to an existing profile class your nodes already include. No new module, no new class hierarchy, no Hiera restructuring. If services need to restart after the library updates, wire in a notify relationship:

package { 'openssl': 
  ensure => '3.5.1', 
  notify => Service['nginx'], 
} 
Enter fullscreen mode Exit fullscreen mode

The notify fires the service restart only after the package actually changes. If the package is already at the right version on the next run, no restart fires. That matters for services where unnecessary restarts are disruptive.

Step 3: Write the Policy

Create or update a security::openssl class. The core pattern is simple: declare the required package version in Hiera, look it up in the manifest, and subscribe dependent services to the package resource so they restart automatically after the library is replaced.

# site-modules/security/manifests/openssl.pp 

class security::openssl ( 
  String $version = lookup('security::openssl::version'), 
) { 

  package { 'openssl': 
    ensure => $version, 
  } 

  # Debian/Ubuntu ships libssl separately 
  if $facts['os']['family'] == 'Debian' { 
    package { 'libssl-dev': 
      ensure => $version, 
    } 
  } 

  # RHEL/Rocky/Alma ships openssl-libs 
  if $facts['os']['family'] == 'RedHat' { 
    package { 'openssl-libs': 
      ensure => $version, 
    } 
  } 

  # Services that need to restart when OpenSSL updates 
  Package['openssl'] ~> Service['nginx'] 
  Package['openssl'] ~> Service['apache2'] 
  Package['openssl'] ~> Service['postfix'] 
  Package['openssl'] ~> Service['sshd'] 
} 
Enter fullscreen mode Exit fullscreen mode

Pin the version in Hiera. Using a version string rather than latest gives you reproducible, auditable rollouts.

# data/common.yaml 
security::openssl::version: '3.5.1' 
Enter fullscreen mode Exit fullscreen mode

Want to vary by OS family? Layer your Hiera:

# data/os/RedHat.yaml 
security::openssl::version: '3.5.1-2.el9' 

# data/os/Debian.yaml 
security::openssl::version: '3.5.1-1ubuntu1' 
Enter fullscreen mode Exit fullscreen mode

Step 4: Verify Services Are Actually Using the New Library

A successful package upgrade does not guarantee the running process is using the new library. Services that were already loaded before the update still have the old .so mapped in memory until they restart.

Confirm post-patching with lsof:

sudo lsof -p $(pgrep nginx | head -1) | grep libssl 
Enter fullscreen mode Exit fullscreen mode

Or simply check the installed version:

openssl version 
Enter fullscreen mode Exit fullscreen mode

If lsof still shows the old library path after Puppet ran, check whether the service restart fired by reviewing the Puppet agent log on that node.

Step 5: Confirm Full Convergence

After the rollout, run a follow-up PuppetDB query to verify closure. This also doubles as audit evidence.

puppet query 'inventory[certname, facts.openssl_version, facts.environment] { 
  facts.openssl_version ~ "^(3\.[0-3]\.|1\.[0-1]\.)" 
}' | jq length 
Enter fullscreen mode Exit fullscreen mode

That number should be zero. If it is not, you have a precise list of which nodes still need attention.

Puppet Enterprise users can pull this same data from the compliance dashboard and export it directly for audit review.

A Note on the puppet/openssl Forge Module

The puppet/openssl Forge module is a legitimate tool for managing PKI entities — keys, CSRs, certificates, DH parameters. If you are already using it for those purposes, there is nothing wrong with it.

For this specific advisory though, it is more than you need. Patching the OpenSSL package is a one-resource job. Pulling in an additional module to do what package { 'openssl': ensure => '3.5.1' } handles natively adds complexity without benefit. Keep it simple.

Handling Edge Cases

Containers running OpenSSL directly

Puppet manages the host OS. If your containers bundle their own OpenSSL, you need to rebuild and redeploy those images. PuppetDB facts can help you identify which hosts are running containers that may have bundled the library, but the remediation path is a container pipeline problem, not a package manager problem.

Systems that cannot take a service restart during business hours

Use Puppet's scheduled tasks or a maintenance window tag in your node group rules to defer the restart to an approved window. The package can update immediately; the service restart can be deferred. This is a better posture than delaying the package update itself.

# Defer restarts to a maintenance window using a fact 
class security::openssl_deferred_restart { 
  package { 'openssl': 
    ensure => '3.5.1', 
  } 

  # Only restart if we are in the declared maintenance window 
  if $facts['maintenance_window'] == true { 
    Package['openssl'] ~> Service['nginx'] 
  } 
} 
Enter fullscreen mode Exit fullscreen mode

EOL systems

If you are running OpenSSL 1.0.x or an OS that ships a version, the vendor no longer patches, this advisory is a forcing function.

Continuous Enforcement: The Part That Actually Matters Long-Term

A one-time patch run is not the goal. The goal is a permanent change to your security posture.

You configure scan frequency – 48X per day by default – Puppet enforces desired state. If a node drifts back to a vulnerable OpenSSL version, whether because of a failed package hold, a reprovisioned VM built from an old image, or a manual change someone made and forgot about, Puppet corrects it on the next run without a ticket or a human in the loop.

For a vulnerability class that attackers are actively targeting within hours of disclosure, that continuous enforcement posture is the actual protection. The patch run will fix the issue one time, continuous enforcement with Puppet keeps you there.

Top comments (1)

Collapse
 
sloan profile image
Sloan the DEV Moderator

Hey, this article appears to have been generated with the assistance of ChatGPT or possibly some other AI tool.

We allow our community members to use AI assistance when writing articles as long as they abide by our guidelines. Please review the guidelines and edit your post to add a disclaimer.

Failure to follow these guidelines could result in DEV admin lowering the score of your post, making it less visible to the rest of the community. Or, if upon review we find this post to be particularly harmful, we may decide to unpublish it completely.

We hope you understand and take care to follow our guidelines going forward!