Meet us at Black Hat USA 2025, Booth 6316 or Book a meeting

All insights

August 6, 2025

22 min read

Exploiting a full chain of trust flaws: how we went from unauthenticated to arbitrary remote code execution (RCE) in CyberArk Conjur

Written by Yarden Porat, Core Team Engineer

Introduction

Enterprise vaults are designed to secure the secrets, credentials, and tokens that control access to everything else. That’s what makes them such prime targets for attackers.

When they succeed at exploiting them, the results can be severe, including enterprise-wide credential theft, data tampering and leakage, operational disruption, and regulatory exposure.

This is why it was so important for us at Cyata to understand just how secure these vaults really are.

Over several weeks of focused research, we analyzed two widely used enterprise secrets management platforms, HashiCorp Vault and CyberArk Conjur. We invite you to read the full breakdown of our findings regarding HashiCorp Vault.

In Conjur, our investigation uncovered a full pre-authentication remote code execution (RCE) chain. Through a series of logic flaws, we demonstrated how to achieve RCE on a Conjur deployment using the default AWS integration setup.

No credentials. No tokens. Not even a real AWS account. Just a carefully crafted series of requests that moved from zero access to full control, all by exploiting default behavior.

This exploit chain doesn’t rely on memory corruption or race conditions. It’s entirely logic-based, combining type confusion flaws and a new attack primitive that bypasses multiple layers of trust enforcement – all using standard requests and default configurations.

It’s the kind of path that’s invisible to traditional defenses and devastating once it’s exploited.

In this post, we walk through what we found, how we found it, and what it means for the infrastructure Conjur is trusted to protect.

These issues have since been fixed by the vendor. To stay protected, update to the latest version as soon as possible.

What is Conjur

CyberArk Conjur is an open-source secrets management solution designed to securely store, manage, and control access to sensitive credentials, API keys, certificates, and other secrets used in DevOps and cloud-native environments.

Built for automation-first infrastructure, Conjur is primarily used to manage machine and AI identities, and broker secure access between services in CI/CD pipelines, Kubernetes clusters, and other dynamic environments.

It integrates with tools like Jenkins, Kubernetes, Ansible, and Terraform, providing policy-based access controls and scalable machine-to-machine authentication.

Its policy engine allows for precise permission scoping and enforcement, and its compatibility with enterprise CyberArk deployments makes it appealing for hybrid cloud security strategies.

In many organizations, Conjur serves as a trust anchor in automated workflows, and as this research shows, a compromise in Conjur can have far-reaching consequences across systems that depend on it.

Conjur Highlights

For more on Conjur, see here.

Methodology

Before we began searching for vulnerabilities, we first set out to understand how Conjur works. Rather than poking around randomly, we focused on its internal design:

We examined the policy language, the structure of resource identifiers, and the way secrets are stored and retrieved.

The first breakthrough came when we reviewed how Conjur handles AWS IAM authentication. From there, we noticed a pattern – critical HTTP endpoints often rely on attacker-controlled inputs like :id, :account, and :kind, without a consistent validation mechanism.

The lack of validation made us think there was a deeper path to explore. So instead of chasing standalone issues, we asked ourselves what a full compromise might require, then worked backward to find the flaws that could make it possible.

The path to compromise

We began by exploring Conjur’s AWS IAM authentication flow, a widely used mechanism that lets workloads authenticate without needing hardcoded credentials. It’s commonly used in CI/CD pipelines, where machines need to retrieve secrets securely.

This integration is quite complex. The AWS instance generates a signed Authorization header, which Conjur passes to AWS Security Token Service (STS). STS verifies the signature and returns the identity of the AWS instance.

But we noticed something interesting in the implementation. AWS runs STS in multiple regions, each with its own endpoint (like sts.us-east-1.amazonaws.com). Conjur doesn’t determine the region on its own, it extracts it from user-supplied parameters. That small detail opened the door to bypassing the verification entirely.

What we looked for

We wanted to understand how Conjur decides which STS endpoint to target during IAM authentication.

In AWS, signed requests are region-specific, which means that a request signed for us-east-1 must be verified against sts.us-east-1.amazonaws.com. So, the key question became – how does Conjur determine the region used in the original signed request?

To answer that question, we search for and located the method in Conjur’s codebase responsible for parsing this value: extract_sts_region.

What we found

The extract_sts_region function attempts to parse the region from either the Host or the Authorization header:

def extract_sts_region(signed_headers)
  host = signed_headers['host']

  if host == 'sts.amazonaws.com'
    return 'global'
  end

  match = host&.match(%r{sts\.([\w\-]+)\.amazonaws\.com})
  return match.captures.first if match

  match = signed_headers['authorization']&.match(%r{Credential=[^/]+/[^/]+/([^/]+)/})
  return match.captures.first if match

  raise Errors::Authentication::AuthnIam::AWSHeaders, 'Failed to extract AWS region from authorization headers'
end

The region is then used to construct the STS domain:

def aws_call(region:, headers:) 
host = if region == 'global' 
'sts.amazonaws.com' 
else 
"sts.#{region}.amazonaws.com"
 End
 aws_request = URI("https://#{host}/?Action=GetCallerIdentity&Version=2011-06-15") 
begin 
@client.get_response(aws_request, headers)
 rescue StandardError => e 
# Handle any network failures with a generic verification error raise(Errors::Authentication::AuthnIam::VerificationError, e) 
End
 end


The extract_sts_region function uses unvalidated, attacker-controlled header content to construct the STS domain. It lacks proper validation for special URL characters. For example, if we send:

Authorization: Credential= A/A/attacker.domain?/

the region is extracted as attacker.domain?, and Conjur constructs the STS endpoint as:

https://sts.attacker.domain?.amazonaws.com

By using the question mark (?) symbol, we were able to strip off the .amazonaws.com portion of the URL. This is critical – because in domain verification, only the last part of the domain actually matters.

Conjur then sends a verification request to that domain, which means that we fully control the endpoint it trusts to validate IAM identities.

To demonstrate the impact, we stood up a mock STS server at sts.cyata.ai, programmed it to return a forged but well-formed GetCallerIdentity response, and watched Conjur accept it as valid.

No AWS credentials is needed for this step. Just logic.

Why it matters

This bypass fundamentally breaks Conjur’s trust boundary.

By redirecting validation to an attacker-controlled STS endpoint, we were able to impersonate any AWS identity we wanted without supplying a single credential.

This was the first step in the chain, where a completely unauthenticated attacker could now enter the system, appearing to be a legitimate AWS identity.

After bypassing IAM authentication, we took a step back to examine how Conjur works behind the scenes, focusing on its policy model and internal data structures.

We looked at how it identifies and manages core resources like hosts, variables, policies, and groups, all of which are stored in an internal PostgreSQL database.

Each resource in the database is indexed using a composite ID made up of three parts:

Here is an example of the resources table:

This structure isn’t just used internally, it’s also exposed externally. Most of Conjur’s HTTP endpoints accept :account, :kind, and :id as URL parameters and rely on them to locate or manipulate resources.

Once we understood how identifiers like :account, :kind, and :id were used, we had a strong feeling that if we could create a resource with a controlled identifier, it would matter.

We didn’t yet know how to do it, or whether it was even possible, but it felt like the kind of primitive worth chasing.

We started examining different endpoints, testing for anything that might let us influence identifiers. Eventually, we found what we needed in the Host Factory – an endpoint that lets you create a host with an arbitrary identifier

What we looked for

We specifically investigated what permissions are required to use the Host Factory.

First, we considered what the standard Host Factory flow is supposed to look like:

  1. Call POST /host_factory_tokens with the ID of a known host factory to get a token
  2. The /host_factory_tokens verifies that we have execute permissions on the specified host factory resource.
  3. Use that token to call POST /host_factories/hosts, supplying an ID for the new host

With this in mind, we wanted to answer two critical questions:

What we found

Host Factory Kind Mismatch

To get a Host Factory token, the caller must have execute permission on the target resource. But Conjur doesn’t verify that the resource is actually of kind host_factory.

That means an attacker can pass any resource they have execute permission on, such as a group or layer, and still receive a valid token.

This opens up a clear abuse path, where basically if we own a group (ownership gives all permission on a resource), we can:

If a client owns a group resource, they can abuse the Host Factory flow to create a new host with an arbitrary identifier. And since they own the resource used to obtain the token, they also become the owner of the newly created host.

Why it matters

We can now create hosts with arbitrary identifiers. This is a powerful primitive, one that opens the door to attacking some of Conjur’s core and most sensitive endpoints.

And the requirement? Just ownership of a group, a small foothold, considering the control it unlocks.

But how can we become the owner of a group?

Would a common configuration be enough? Probably, but we wanted to assume as little as possible. So, here’s the only assumption we made: AWS authentication is enabled.

That means we needed to find a default path to group ownership. To do that, we revisited the IAM configuration from Conjur’s official documentation.

# policy id needs to match the convention `conjur/authn-iam/<service ID>`
- !policy
  id: conjur/authn-iam/prod
  body:
    - !webservice

    - !group clients

    - !permit
      role: !group clients
      privilege: [ read, authenticate ]
      resource: !webservice

In the example policy, we see a group called clients. If we can become the owner of that group, the rest of the chain falls into place.

What we looked for

We started by asking: Can we use the AWS bypass to authenticate as user:admin? In this configuration admin is the owner of our policy, and therefore the owner of the group.

What made us think this was possible?

We noticed that Conjur doesn’t validate the kind of identity during authentication. That raised an interesting possibility: what if we could authenticate as a user instead of a host?

But a restrictive check stood in the way: IAM allows authentication as a user only if the resource_id contained a slash (/).

That meant user:admin was off the table.

What we found

The ability to authenticate as a user turned out to be just the tip of the iceberg.

The AWS IAM bypass actually lets us authenticate as any resource.

Why? Because all resources in Conjur are stored in the same PostgreSQL table, and the permissions system is global across all types.

There’s no distinction between identity resources (host, user) and role resources (group, layer, policy)- they’re all treated the same by the authorization engine.

This means we can go one step further:

Why it matters

This step changed the game. It bridged the gap between unauthenticated access and a meaningful attack surface in Conjur.

By combining a forged STS response, a policy identity instead of a host, and flaws in the Host Factory flow, we gained the ability to mint new hosts with names and ownership entirely under our control.

With the ability to create arbitrary hosts through the Host Factory, we started thinking about what we could do with that control.

At first, we focused on privilege escalation, maybe gaining access to restricted variables or impersonating higher-privileged roles. But the deeper we looked into how Conjur structures policy and manages secrets, the more dangerous the path became.

That’s when we found the Policy Factory, a mechanism that lets you apply reusable policy templates programmatically.

But these templates aren’t static YAML files. They support ERB (Embedded Ruby), which means every time a template is applied, it gets rendered and executed dynamically.

And that raised a critical question.

What if we could control what gets executed?

What we looked for

We knew ERB was being executed – the question was whether we could influence it.

To find out, we focused on three key questions:

Answering these questions meant diving into how templates are applied behind the scenes, and into how Conjur loads them, renders them, and evaluates them through its API.

What we found

Policy Factory

The Policy Factory is triggered through a dedicated endpoint:

POST "/factories/:account/:kind(/:version)/:id"

This endpoint is a bit confusing. It takes the account, kind, version, and id parameters, then searches for a matching policy template under the conjur/factories branch.

Note: The kind parameter does not refer to the resource type – the resource is always expected to be a variable. Instead, kind is used purely as part of the lookup path within the conjur/factories namespace.

The relevant code looks like this:

def find(kind:, id:, account:, role:, version: nil)
  factory = if version.present?
    @resource["#{account}:variable:conjur/factories/#{kind}/#{version}/#{id}"]
  else
    @resource.where(
      Sequel.like(
        :resource_id,
        "#{account}:variable:conjur/factories/#{kind}/%"
      )
    ).all
  end
end

This code locates a variable resource – but the template itself is stored inside the associated secret. In the next step, the code takes the resource_id of the variable, uses it to look up thesecret, and then decrypts its value from the secrets table.

That decrypted value is the policy template.

ERB in Policy Templates

Why do we care about policy templates?

Because these templates aren’t just static YAML files, rather they’re ERB-rendered. Ruby code can be embedded and executed every time a template is applied.

For example, here’s a policy template from CyberArk documentation:

module PolicyTemplates
  class CreateHost < PolicyTemplates::BaseTemplate
    def template
      <<~TEMPLATE
      - !host
        id: "<%= id %>"
        <% unless annotations.nil? || annotations.empty? %>
        annotations:
        <% annotations.each do |key, value| %>
          <%= key %>: <%= value %>
        <% end %>
        <% end %>
      TEMPLATE
    end
  end
end

That’s why, at first, we considered finding a way to abuse an existing, configured template. But, Conjur has no built-in templates. Each one must be defined manually.

So we asked a bigger question: instead of abusing an existing template, what if we could control the entire template?

That would allow us to inject arbitrary Ruby code, and have it executed.

No race conditions. No memory corruption.

Assigning a Secret to a Host

So how can we create a policy template?

As noted earlier, policy templates are stored in the secrets table, and secrets are supposed to be assigned only to variables.

But we discovered a critical gap: nothing actually enforces that rule.

That meant we could assign a secret containing ERB to a resource of kind host.

For example, consider the following resource_id:

MyAccount:host:conjur/factories/my_template

We were able to assign a secret to this resource, even though it is a host, not a variable.

Injecting a host as a variable

There was still one missing piece. How could we make Conjur treat our host as a policy template?

After all, policy templates are supposed to be stored as variables.

Let’s take another look at the code:

module PolicyTemplates
  class CreateHost < PolicyTemplates::BaseTemplate
    def template
      <<~TEMPLATE
      - !host
        id: "<%= id %>"
        <% unless annotations.nil? || annotations.empty? %>
        annotations:
        <% annotations.each do |key, value| %>
          <%= key %>: <%= value %>
        <% end %>
        <% end %>
      TEMPLATE
    end
  end
end

We can see that the expected resource_id looks like this:

“{Account}:variable:conjur/factories/{kind}”.

Here there is a specific check that the kind is variable. So, what can we do?

As mentioned earlier, there are several cases of missing validation. And here, there is no validation on the account parameter.

We can use the HTTP parameter account as both the account and the kind component of the resource_id.

Therefore, we can call the policies endpoint, with account = “MyAccount:host”.

This results in a resource_id of:

MyAccount:host:variable:conjur/factories/{kind}.

So, if we create an arbitrary host with an identifier of:

“variable:conjur/factories/”

Then our host will have a full resource_id of:

“MyAccount:host:variable:conjur/factories/{kind}.”.

This means, the search will work, and our created secret will be rendered as a policy template, executing arbitrary code.

Why it matters

This was the final step, and the one that delivered full remote code execution.

The code ran because:

This vulnerability turned arbitrary host creation into arbitrary command execution.

It was a seamless, start-to-finish exploit chain that went from unauthenticated access to root.

Putting it all together: Full exploit chain

Now that we had each vulnerability mapped, it was time to connect the dots.

We had fully compromised Conjur’s authorization and access controls. And with that, we were able to execute the full exploit chain.

Here’s how it unfolded:

1. IAM authentication bypass

We injected a fake region in the AWS-signed request, causing Conjur to send the STS validation call to a server we controlled (e.g., sts.cyata.ai).

That gave us the power to forge valid-looking GetCallerIdentity responses, impersonating any identity we wanted.

2. Authenticate as a policy

Instead of impersonating a host (as the IAM flow expects), we authenticated as a policy resource, which Conjur accepted under its default IAM configuration.

This immediately gave us elevated privileges, by design.

3. Abuse the Host Factory

Now acting as a policy, we invoked the POST /host_factory_tokens endpoint, passing a group resource we controlled.

Because Conjur didn’t verify the resource kind, we received a valid token, despite never using a real host factory.

4. Create a host named like a variable

With the token, we created a new host and crafted its resource_id to look like a policy template path.

/variable/conjur/factories/malicious_template

This host was now positioned to impersonate a valid policy template.

5. Attach ERB code as a secret

Conjur is supposed to create secrets on variables only, but that enforcement was missing.

So, we assigned a malicious ERB payload directly to the host, the same host that would later be treated as a template.

6. Trigger ERB execution

Finally, we called the Policy Factory endpoint. It looked up the template by resource_id, found our crafted host, extracted the attached ERB, and executed it, exactly as designed.

Final result

This exploit chain moved from unauthenticated access to full remote code execution without ever supplying a password, token, or AWS credentials.

Every step used default behavior. Nothing looked out of place. Until it was too late.

Responsible disclosure

Throughout this research, Cyata adhered to a thorough and responsible disclosure process. We engaged CyberArk early, providing detailed technical reports for each finding – from the initial authentication bypass to the full exploit chain.

The collaboration was transparent, focused, and genuinely constructive.

CyberArk’s security team responded with professionalism and clarity, working closely with us to verify the issues and implement timely fixes ahead of public disclosure. Their responsiveness, openness, and strong communication set a high bar – and we sincerely wish more vendors handled security research this way.

Kudos to the team at CyberArk – and congratulations on the recent acquisition.

Disclosure timeline

Conclusion

Secrets vaults sit at the heart of modern infrastructure. They secure the credentials, tokens, and keys that power everything else. That’s exactly what makes them so valuable, and so vulnerable.

At Cyata, we see ensuring the resilience of these systems as one of the most critical cybersecurity mandates today. That’s why we invest in deep, targeted vulnerability research not just to uncover flaws, but to help organizations build trust into the very core of their environments.

By proactively probing and challenging the systems that manage identity, access, and secrets, we aim to reduce risk, prevent breaches, and strengthen the foundations of the digital world.

And we’re just getting started

The Control Plane 
for Agentic Identity

More insights

Blog

Sign up for Cyata’s Newsletter

Get early access, research, and updates from the leaders in Agentic Identity.

By submitting, you agree to our Privacy Policy