DEV Community

loading...

Overwriting Shared Libraries in AWS Lambda

Nick Silverman
・3 min read

The latest Ruby runtime for AWS Lambda runs Ruby 2.7. Though this version of ruby is only 6 months old, the version of OpenSSL that Lambdas instance of Ruby was compiled with is over 3 years old. You can verify that by running the function below and seeing what it returns:

require 'openssl'

def lambda_handler(event:, context:)
    return OpenSSL::OPENSSL_VERSION
end

# OpenSSL 1.0.2k  26 Jan 2017

That's Old! That means that Ruby's OpenSSL library is missing some key features like SHA-3, TLS 1.3, and the scrypt KDF.

I wanted to see if I could load in a newer version of the OpenSSL shared library ruby loads so I could leverage some of these shiny new features. Well, it turns out, AWS Lambda Layers was a big part of the answer here. In the documentation, a Lambda Layer is available to your Lambda code via the /opt directory. Now anyone who uses a lot of gem dependencies might have already come across this feature as it's a great way to share gems across different functions while keeping the function size itself fairly small.

But interestingly enough, it's not just a place to load gems. Lambda also adds to the RUBYLIB environment variable with a path you can fill with a Lambda Layer (specifically /opt/ruby/lib). This path is also prefixed to the LOAD_PATH variable. This is where things get interesting.

Now that we know we can load up a Lambda Layer with a shared library that will be part of the auto searched LOAD_PATH, we can construct a Lambda Layer with the necessary files to load our own version of OpenSSL. To do this, we need a newer instance of openssl.so that was compiled with Ruby and we also need the libssl.so.1.1 and libcrypto.so.1.1 files to support the shared library.

I was able to extract a copy of these files by installing the latest version of OpenSSL from my package manager (pacman), and installing Ruby 2.7 from RVM so it re-compiled on my machine. In the end, I constructed a directory structure that looked like this:

.
├── lib
│   ├── libcrypto.so -> libcrypto.so.1.1
│   ├── libcrypto.so.1.1
│   ├── libssl.so -> libssl.so.1.1
│   └── libssl.so.1.1
└── ruby
    └── lib
        └── openssl.so

I then zipped that up and uploaded that zip to a new Lambda Layer destined for my function. Upon running the below function, we can see that my OpenSSL version is now nice and new and should include the features I want! Running the original function above, I now see OpenSSL 1.1.1d 10 Sep 2019. Excellent! Now I can go generate all the scrypt keys and initiate all the TLS 1.3 connections I want right?

Not exactly. It turns out, Ruby has a fun little behavior when it sees it needs to load some files. when calling require, ruby will search through the LOAD_PATH for the code you are trying to load, but specifically with require, it will load .rb files and shared libraries with the .so extension. So When I tried to create a new SHA-256 digest, I was met with an unexpected error:

require 'openssl'

def lambda_handler(event:, context:)
    return OpenSSL::Digest::SHA256.new
end

# uninitialized constant OpenSSL::Digest::SHA256

What happened? Well it turns out, because my openssl.so file is now ahead of Ruby's built-in openssl.rb code, I am only loading the shared library which comes with some classes, but not all the classes I expect. To get around this, it's quite simple:

require 'openssl.rb'

def lambda_handler(event:, context:)
    return OpenSSL::Digest::SHA256.new
end

# #<OpenSSL::Digest::SHA256: ...>

By specifying the .rb extension, I am now instructing Ruby to look through its LOAD_PATH until it finds the first instance of a file called openssl.rb. This is included with ruby and is the code that loads in all of the classes I expect to see, as well as an explicit call to load openssl.so. This now allows me to use all of the shiny new features that OpenSSL 1.1.1(x) provides without having to use a Custom Runtime.

Discussion (0)