First Time Exploiting Shell Injection

I was contributing to a project, Letta Code. After a period away from work, I began contributing to open-source projects again. After my second pull request (PR) got merged, I started to explore stale PRs which were not merged for a while. I wanted to know if anyone has fixed issue that I faced but still not reviewed by the maintainer. One PR caught my eye, it mentioned about security but the title was quite vague.

fix(security): 2 improvements across 1 files

The PR description explains about the severity and what was the vulnerability. In short, it is a shell injection vulnerability. This occurs when a normal input is used to execute a shell command in a machine.

The PR diff (or change) is also very simple. Replacing execSync with execFileSync. What are those?

Both are from NodeJS child_process standard library, it is used to execute a command in a shell but they treat the input differently. For example let’s say I have a file called echo.ml with this content

print_endline "boo"

A single line to print a text “boo”. If I want to execute this code through NodeJS I can do it like this,

const filename = "echo.ml"

child_process.execSync(`ocaml ${filename}`, {encoding: "utf8"}) // Encoded to UTF-8 for clarity

The output is like the picture below,

The output is "boo"

It manages to run a command and get the output. However, let’s try something different, what if, the filename is not normal filename but something that mimics a valid bash syntax? Let’s try this

const filename = "echo.ml; curl https://httpbin.org/headers"

child_process.execSync(`ocaml ${filename}`, {encoding: "utf8"})

So I changed the output to include a command to fetch a URL using curl command. However, since this is a filename, it should be just printing “boo\n” like before. Let’s see the output,

The output is printing "boo" but also response from curl command

Did you see it? The filename is now interpreted as a valid shell command! It executes curl and return its response!

What does it mean? If you look back into the PR I mentioned, it replaces the execSync with execFileSync. Which means, the new change should prevent this from happening. Let’s prove it!

const filename = "echo.ml; curl https://httpbin.org/headers"

// Replaced with execFileSync
child_process.execFileSync("ocaml", [filename], {encoding: "utf8"})

So I replaced with execFileSync and change the argument according to the docs. Here’s the output

The filename is unrecognised since it is not found

Oh, wow. It didn’t run anything. Yet let’s verify again if I revert to original filename echo.ml it should execute code in the file

The output is "boo" which is expected output from the file

Awesome! It really fixes the issue. This means the PR is a valid fix but it’s stale for sometime when I was reviewing it. I wonder how can I convince the maintainer to merge it. Maybe, they need proof to see how significant the impact is.

“They always need to have things explained”

- Little Prince by Antoine De Saint-Exupéry

Reminiscence

When looking at this PR, I wonder how can I show the impact of this. I’m not entirely sure about it, even if I have to dig down into the code, I would face with many unknowns and dragged to learn other preliminary things. Then I remembered about an article of how attackers, even the lower class, could leverage the same technique like the sophisticated actor. It was a report from Anthropic, their red team, which reported how threat actors weaponizing their model.

“Traditionally, only the most technically sophisticated actors could operate across the entire killchain, or the sequential stages of a cyberattack. But our analysis found that this is no longer the case. … What does distinguish the highest-risk actors is which techniques they’re asking the model for.” - Anthropic

Also there was another report I ever read which shows how a threat actor exactly did it. Instead of diving deeper into the bits, we just need to “ask” the model then it scans the whole codebase or use tools to poke around the system.

A Little Doubt

While this sounds easy and convenient, I was contemplating whether I should use model or explore it myself. On one side, it is about speed to discover. On other hand, it is about embarking on a journey of discovery. My take is, this is a known vulnerability and the fix is ready but not merged yet. User is already using the tool. So I decided to use AI to help creating a POC for the exploit.

Going Red

Well I may be exaggarating but this is my first time I focus on using AI to exploit an existing project made by someone else. It sounds harmful and wrong but my intent is not to be silent about it. Thus, I take role to be a kid in the red team for this case.

I used the model to explore the PR and verify the fix. It traces the execution and told me it all started when user run letta --import

For context, Letta Code has a feature to import an agent. It is by importing .af file or so called Agent File. “It provides a portable way to share agents with persistent memory and behavior across different environments.” according to their docs. There are two ways to import an agent, from a file or Letta registry. User only need to run

# import from local file
letta --import <filepath>

# import from Letta registry
letta --import <@author/name>

then it will import the agent memory, skills, and personality to be available in the user machine. Let’s focus on the skills part. The agent file include skills section to state what skills the agent have. There are 2 ways to import skill, from filepath and from GitHub repository. I’m focusing the example for importing skill from GitHub. It looks like this

{
    "name": "drawing",
    "source_url": "<YOUR_GITHUB_USER>/<REPOSITORY_NAME>/main/skill"
}

What this mean is, after the user run letta --import agent.af it will import the agent into the user machine, then when importing the agent skills, it runs a command in the background to download from a GitHub repository. The PR that I was looking at is the one that was responsible to download skills from GitHub. In other words, the vulnerability exists only if the skill is imported remotely, in this case, from GitHub. We found an attack vector.

Proof of Exploit

I want to test what Anthropic claims in the report, let’s use AI to exploit this. I was using GPT 5.5 (medium reasoning) if that matters. I asked it to create real world scenario of the attack and guide me to execute the attack.

Skill Repository

First step is, of course, I need a GitHub repository. So I created one. what’s inside the repository must be a valid skill file and directory. Normally it looks like this,

.
└── skill
    └── SKILL.md

Taking a step back a little. If we look into how the implementation of downloading the skill, it looks like this:

// Note: simplified for brevity
// ...
  for (const entry of entries) {
    if (entry.type === "file") {
        // ...
    } else if (entry.type === "dir") {
      // Recursively fetch subdirectory
      const subEntries = await fetchGitHubContents(
        owner,
        repo,
        branch,
        entry.path,
      );
      await downloadGitHubDirectory(
        subEntries,
        destDir,
        owner,
        repo,
        branch,
        basePath,
      );
    }
  }

// ...

As we can see, it will check every file and directory in the GitHub repository and when it found a directory, it will run fetchGitHubContents function which was fixed in the PR. That means, the directory name is used as the input for .execSync method. We found another attack vector.

Second step is, we create a new directory inside skills directory. This is normal directory but with unsual name

x;touch PR2577_POC_MARKER;printf '[]' #

and inside it we can just add a dummy file. Call it .keep. So now the repository content looks like this,

.
└── skill
    ├── SKILL.md
    └── x;touch PR2577_POC_MARKER;printf '[]' #
        └── .keep

What’s with the cryptic directory name? If we break it down, it looks like this

x;
touch PR2577_POC_MARKER;
printf '[]' #

Yes, the first x is just a decoy, an invalid bash command yet the rest is a valid bash command. Since we use ; (semicolon), it means the shell will continue with the next command if the former fails.

Now, how the exploit works? Let’s draw a scenario

User unknowingly import agent file which run a shell command

Well, I simplified it too much. It works like this:

  1. User already have Agent File in their filesystem.
  2. User runs letta --import good-looking-agent.af
  3. Letta Code read the agent file and found the agent has a skill that is hosted in GitHub
  4. Letta Code fetches the GitHub repository and download the subdirectory recursively
  5. Letta Code encounter a directory x;touch PR2577_POC_MARKER;printf '[]' #
  6. It runs shell command
gh api x;touch PR2577_POC_MARKER;printf '[]' #

Do you notice something? The command itself looks right but the repository name/path is invalid. However, since there is ; in the command, it keeps executing the rest. This also means, even if the user doesn’t have gh CLI installed, this attack still works.

Agent File

Third step is, creating the Agent File. For this POC I created locally to test whether we can exploit it. Regardless where the Agent File is located, the problem is what’s inside the Agent File. So I created it by using /export command. This command is used to create an Agent File, say you have favorite AI agent and wanted to share it with your friend, all we need to do is just run /export then send the file to your friend.

It exports a file called agent-28bc9878-687f-443f-9354-ac0b571d431c.af

The output is agent-28bc9878-687f-443f-9354-ac0b571d431c.af which is the agent ID in my machine. Next, we modify the skills section so it will fetch from a repository that I just created.

...
  "skills": [
    {
      "name": "drawing",
      "source_url": "KY64/letta-af-exploit-poc/main/skill"
    }
  ],
...

Done, I saved it with name good-looking-agent.af as a copy.

Real Test

Moment of truth, let’s try import this nice looking agent file.

letta --import good-looking-agent.af

This is what I get

The directory resolved into a shell command that creates a new file PR2577_POC_MARKER

Boom. There you see it. The file PR2577_POC_MARKER is created! This is the exact name that exists in the skill repository.

x;touch PR2577_POC_MARKER;printf '[]' #

This means that when Letta Code import the agent file, it also executes a shell command based on directory name in the skill repository. We just made a proof of a real exploit!

Fortunately

I pinged Letta team to take a look at the PR, they’re quite responsive in the Discord channel. They immediately look into it and approve then merge the fix.

Despite they make new releases pretty frequent, they missed a crucial PR and oversight happens. Anyway, I’d like to express my gratitude to Letta team for merging the fix and @tomaioo for creating the PR.

What can we learn from this? AI is pretty good at finding vulnerability and exploit it. The report is right. My example here may be using a frontier model, yet sometime later the rest model would be able to catch up. After that, it’s a matter of speed, who can patch it first or exploit it with AI.