Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions lib/API.h
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,13 @@ enum CALLBACK_ID {
MAX_CALLBACK_ID
};

typedef char *(*ContextCallback)(int);
typedef struct {
char *data;
int len;
} CallbackResult;

static char *call(ContextCallback callback, int callback_id) {
typedef CallbackResult (*ContextCallback)(int);

static CallbackResult call(ContextCallback callback, int callback_id) {
return callback(callback_id);
}
14 changes: 11 additions & 3 deletions lib/php-extension/GoWrappers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ static inline std::string GetEventCacheField(std::string EventCache::*field) {
/*
Callback wrapper called by the RequestProcessor (GO) whenever it needs data from PHP (C++ extension).
*/
char* GoContextCallback(int callbackId) {
CallbackResult GoContextCallback(int callbackId) {
Comment thread
PopoviciMarian marked this conversation as resolved.
std::string ctx;
std::string ret;

Expand Down Expand Up @@ -158,13 +158,21 @@ char* GoContextCallback(int callbackId) {

if (!ret.length()) {
AIKIDO_LOG_DEBUG("Callback %s -> NULL\n", ctx.c_str());
return nullptr;
return CallbackResult{nullptr, 0};
}

if (ret.length() > 10000) {
AIKIDO_LOG_DEBUG("Callback %s -> (Result too large to print)\n", ctx.c_str());
} else {
AIKIDO_LOG_DEBUG("Callback %s -> %s\n", ctx.c_str(), ret.c_str());
}
return strdup(ret.c_str());

int len = static_cast<int>(ret.length());
char *buf = static_cast<char *>(malloc(len));
if (!buf) {
AIKIDO_LOG_WARN("Failed to allocate %d bytes in GoContextCallback\n", len);
return CallbackResult{nullptr, 0};
}
memcpy(buf, ret.data(), len);
Comment thread
PopoviciMarian marked this conversation as resolved.
return CallbackResult{buf, len};
}
19 changes: 14 additions & 5 deletions lib/php-extension/HandleQueries.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ AIKIDO_HANDLER_FUNCTION(handle_pre_pdo_query) {
eventId = EVENT_PRE_SQL_QUERY_EXECUTED;
auto& eventCacheStack = AIKIDO_GLOBAL(eventCacheStack);
eventCacheStack.Top().moduleName = "PDO";
eventCacheStack.Top().sqlQuery = ZSTR_VAL(query);
eventCacheStack.Top().sqlQuery = std::string(ZSTR_VAL(query), ZSTR_LEN(query));
eventCacheStack.Top().sqlDialect = GetSqlDialectFromPdo(pdo_object);
}

Expand All @@ -50,7 +50,7 @@ AIKIDO_HANDLER_FUNCTION(handle_pre_pdo_exec) {
eventId = EVENT_PRE_SQL_QUERY_EXECUTED;
auto& eventCacheStack = AIKIDO_GLOBAL(eventCacheStack);
eventCacheStack.Top().moduleName = "PDO";
eventCacheStack.Top().sqlQuery = ZSTR_VAL(query);
eventCacheStack.Top().sqlQuery = std::string(ZSTR_VAL(query), ZSTR_LEN(query));
eventCacheStack.Top().sqlDialect = GetSqlDialectFromPdo(pdo_object);
}

Expand All @@ -67,10 +67,19 @@ AIKIDO_HANDLER_FUNCTION(handle_pre_pdostatement_execute) {
return;
}

if (!stmt->query_string) {
return;
}

eventId = EVENT_PRE_SQL_QUERY_EXECUTED;
auto& eventCacheStack = AIKIDO_GLOBAL(eventCacheStack);
eventCacheStack.Top().moduleName = "PDOStatement";
eventCacheStack.Top().sqlQuery = PHP_GET_CHAR_PTR(stmt->query_string);
eventCacheStack.Top().moduleName = "PDOStatement";

#if PHP_VERSION_ID >= 80100
eventCacheStack.Top().sqlQuery = std::string(ZSTR_VAL(stmt->query_string), ZSTR_LEN(stmt->query_string));
#else
eventCacheStack.Top().sqlQuery = std::string((char*)stmt->query_string);
Comment thread
PopoviciMarian marked this conversation as resolved.
#endif

#if PHP_VERSION_ID >= 80500
if (!stmt->database_object_handle) {
Expand Down Expand Up @@ -121,6 +130,6 @@ AIKIDO_HANDLER_FUNCTION(handle_pre_mysqli_query){
eventId = EVENT_PRE_SQL_QUERY_EXECUTED;
auto& eventCacheStack = AIKIDO_GLOBAL(eventCacheStack);
eventCacheStack.Top().moduleName = "mysqli";
eventCacheStack.Top().sqlQuery = query;
eventCacheStack.Top().sqlQuery = std::string(query, queryLength);
eventCacheStack.Top().sqlDialect = "mysql";
}
10 changes: 5 additions & 5 deletions lib/php-extension/Server.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ zval* Server::GetServerVar() {
std::string Server::GetVar(const char* var) {
GET_SERVER_VAR();
zval* data = zend_hash_str_find(Z_ARRVAL_P(serverVars), var, strlen(var));
if (!data) {
if (!data || Z_TYPE_P(data) != IS_STRING) {
return "";
}
return Z_STRVAL_P(data);
return std::string(Z_STRVAL_P(data), Z_STRLEN_P(data));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetVar now constructs and returns a std::string copy for each call, causing a heap allocation and memory copy per call; consider returning a non-owning view or deferring copies to callers.

Details

✨ AI Reasoning
​GetVar was modified to allocate and copy the PHP string into a std::string on every call. The function is used by many higher-level methods (method, URL, headers, etc.), so each call now incurs heap allocation and memory copy. This increases work proportional to the data size per call and multiplies across callers, turning many cheap O(1) pointer accesses into O(n) allocations and copies. Avoidable repeated allocations are an obvious performance regression.

🔧 How do I fix it?
Move constant work outside loops. Use StringBuilder instead of string concatenation in loops. Cache compiled regex patterns. Use hash-based lookups instead of nested loops. Batch database operations instead of N+1 queries.

Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

}

// Return the method from the query param _method (_GET["_method"])
Expand All @@ -48,7 +48,7 @@ std::string Server::GetMethodFromQuery() {
if (Z_TYPE_P(query_method) != IS_STRING) {
return "";
}
std::string query_method_str = Z_STRVAL_P(query_method);
std::string query_method_str(Z_STRVAL_P(query_method), Z_STRLEN_P(query_method));
return ToUppercase(query_method_str);
}

Expand Down Expand Up @@ -160,9 +160,9 @@ std::string Server::GetHeaders() {
zval* val;
ZEND_HASH_FOREACH_STR_KEY_VAL(Z_ARRVAL_P(serverVars), key, val) {
if (key && Z_TYPE_P(val) == IS_STRING) {
std::string header_name(ZSTR_VAL(key));
std::string header_name(ZSTR_VAL(key), ZSTR_LEN(key));
std::string http_header_key;
std::string http_header_value(Z_STRVAL_P(val));
std::string http_header_value(Z_STRVAL_P(val), Z_STRLEN_P(val));

if (header_name.find("HTTP_") == 0) {
http_header_key = header_name.substr(5);
Expand Down
6 changes: 3 additions & 3 deletions lib/php-extension/Utils.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -82,16 +82,16 @@ std::string ArrayToJson(zval* array) {
zval *val;
ZEND_HASH_FOREACH_STR_KEY_VAL(Z_ARRVAL_P(array), key, val) {
if(key && val) {
std::string key_str(ZSTR_VAL(key));
std::string key_str(ZSTR_VAL(key), ZSTR_LEN(key));
if (Z_TYPE_P(val) == IS_STRING) {
query_json[key_str] = Z_STRVAL_P(val);
query_json[key_str] = std::string(Z_STRVAL_P(val), Z_STRLEN_P(val));
}
else if (Z_TYPE_P(val) == IS_ARRAY){
json val_array = json::array();
zval *v;
ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(val), v) {
if (Z_TYPE_P(v) == IS_STRING) {
val_array.push_back(Z_STRVAL_P(v));
val_array.push_back(std::string(Z_STRVAL_P(v), Z_STRLEN_P(v)));
}
}
ZEND_HASH_FOREACH_END();
Expand Down
2 changes: 1 addition & 1 deletion lib/php-extension/include/GoWrappers.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ GoString GoCreateString(const std::string&);

GoSlice GoCreateSlice(const std::vector<int64_t>& v);

char* GoContextCallback(int callbackId);
CallbackResult GoContextCallback(int callbackId);
3 changes: 1 addition & 2 deletions lib/php-extension/include/Includes.h
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,8 @@ using json = nlohmann::json;
#include "zend_builtin_functions.h"

#include "GoCGO.h"
#include "GoWrappers.h"

#include "../../API.h"
#include "GoWrappers.h"
#include "php_aikido.h"
#include "Environment.h"
#include "Stats.h"
Expand Down
8 changes: 4 additions & 4 deletions lib/request-processor/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,18 +119,18 @@ func GoContextCallback(instance *instance.RequestProcessorInstance, contextId in
}

contextCallback := (C.ContextCallback)(contextCallbackPtr)
contextData := C.call(contextCallback, C.int(contextId))
if contextData == nil {
result := C.call(contextCallback, C.int(contextId))
if result.data == nil {
return ""
}

goContextData := C.GoString(contextData)
goContextData := C.GoStringN(result.data, C.int(result.len))

/*
In order to pass dynamic strings from the PHP extension (C++), we need a dynamically allocated buffer, that is allocated by the C++ extension.
This buffer needs to be freed by the RequestProcessor (Go) once it has finished copying the data.
*/
C.free(unsafe.Pointer(contextData))
C.free(unsafe.Pointer(result.data))
// Remove invalid UTF8 characters (normalize)
goContextData = strings.ToValidUTF8(goContextData, "")
return goContextData
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"AIKIDO_BLOCK": "1",
"AIKIDO_LOCALHOST_ALLOWED_BY_DEFAULT": "0",
"AIKIDO_FEATURE_COLLECT_API_SCHEMA": "1"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"type": "detected_attack",
"request": {
"headers": {
"content_type": [
"application/json"
]
},
"method": "POST",
"body": "{\"userId\": \"1\\u0000 OR 1=1\"}",
"source": "php",
"route": "/testDetection"
},
"attack": {
"kind": "sql_injection",
"operation": "mysqli->query",
"module": "mysqli",
"blocked": true,
"source": "body",
"path": ".userId",
"payload": "1\u0000 OR 1=1",
"metadata": {
"dialect": "mysql",
"sql": "SELECT * FROM users WHERE id = 1\u0000 OR 1=1"
},
"user": {
"id": "12345",
"name": "Tudor"
}
},
"agent": {
"library": "firewall-php"
}
}
32 changes: 32 additions & 0 deletions tests/server/test_sql_injection_null_byte_bypass_mysqli/index.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

\aikido\set_user("12345", "Tudor");

$mysqli = new mysqli("127.0.0.1", "root", "pwd", "db");

if ($mysqli->connect_error) {
die("Connection failed: " . $mysqli->connect_error);
}

$createTable = "
CREATE TEMPORARY TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255),
email VARCHAR(255)
)
";
$mysqli->query($createTable);

$mysqli->query("INSERT INTO users (name, email) VALUES ('John Doe', 'john@example.com')");

$requestBody = file_get_contents('php://input');
$data = json_decode($requestBody, true);

$userId = $data['userId'];
$result = $mysqli->query("SELECT * FROM users WHERE id = " . $userId);

echo "Query executed!";

$mysqli->close();

?>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"success": true,
"serviceId": 1,
"heartbeatIntervalInMS": 600000,
"endpoints": [],
"blockedUserIds": [],
"allowedIPAddresses": [],
"receivedAnyStats": true,
"block": true
}
30 changes: 30 additions & 0 deletions tests/server/test_sql_injection_null_byte_bypass_mysqli/test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import requests
import time
import sys
from testlib import *

'''
Test that SQL injection payloads containing embedded null bytes (\x00) are
still detected and blocked. Before the fix, null bytes caused truncation in
the C++/Go context pipeline, letting the detection logic see only the benign
prefix (e.g. "1") while MySQL executed the full payload.
'''

def check_null_byte_sqli_blocked(response_code, response_body, event_id, expected_json):
response = php_server_post("/testDetection", {"userId": "1\x00 OR 1=1"})
assert_response_code_is(response, response_code)
assert_response_body_contains(response, response_body)

mock_server_wait_for_new_events(5)

events = mock_server_get_events()
assert_events_length_is(events, event_id + 1)
assert_started_event_is_valid(events[0])
assert_event_contains_subset_file(events[event_id], expected_json)

def run_test():
check_null_byte_sqli_blocked(500, "", 1, "expect_detection_blocked.json")

if __name__ == "__main__":
load_test_args()
run_test()
Loading