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,

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,

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

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

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

Well, I simplified it too much. It works like this:
- User already have Agent File in their filesystem.
- User runs
letta --import good-looking-agent.af - Letta Code read the agent file and found the agent has a skill that is hosted in GitHub
- Letta Code fetches the GitHub repository and download the subdirectory recursively
- Letta Code encounter a directory
x;touch PR2577_POC_MARKER;printf '[]' # - 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.

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

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.