Patching MacForge to remove start process_extensions logs

I was hitting a problem where some VSCode plugins I use were erroring out when running commands. After slowly paring away at their source code, I realized they were failing for the same reason: they called osascript 1 osascript is a command-line tool on macOS that runs AppleScript or JavaScript scripts, mainly for automation tasks. Think of it as the runner for the macOS scripting language. in a way that was supposed to return JSON, but instead of getting something like {"key": "value"} they were getting start process_extensions{"key": "value"}, causing the JSON parsing to fail. But Googling that prefix only brought up one reference on Google, a GitHub issue where the author wasn’t sure what was causing the problem or how to fix it. Let's try and figure it out!

Find a narrow repro

As with all debugging, you want to start by finding the simplest case that reproduces your problem. 2 Given how much of my day job is debugging I eventually want to write up something more concrete about my process, but in the meantime here's someone else's that I enjoyed. This minimizes the number of things that could be causing the problem, and allows you to quickly check if it's fixed. The plugins were using complicated osascript commands that called Xcode, but I managed to pare them down to a simple script that should just print out "test" to the screen...

osascript -e 'do shell script "echo test"'

...if it were working correctly.

echo test works fine though, and this same discrepancy happened regardless of whether I was using zsh or bash. 3 I even compiled my own C and Obj-C scripts that printed out "test" with clang, but running them didn't have the prefix—there's something special about osascript (or really, a .app environment). However when I booted into recovery mode and used the Terminal there the prefix vanished. So something I had installed was injecting itself into random processes...hey wait a second. That's what MacForge does!

MacForge

MacForge is a plugin manager for macOS, which allows you to inject code into other apps or system applications. I have it installed for SpacesRenamer 4 As the name implies, it allows you to rename Spaces. The GitHub link has more details, but it’s generally composed of two pieces—I set the space names: and they show up in the system UI (featuring some light teasers for WIP blog posts): (my most-starred GitHub project and likely the software I've written that I get the most use out of). We can confirm this by using the MacForge option to disable injection. If we do that, start process_extensions disappears—we have our culprit.

The version of MacForge that works on macOS 14.14+ was compiled by @jslegendre. Unfortunately while MacForge is open source the changes that Jeremy made were not, and while I flagged this to him on Discord back in February he never got back to me.

At the time, this is where I left it. Without the code I couldn't see what he'd changed, and couldn't compile a fixed version, so I just disabled injection and moved on. But then a few months passed, and I did some reverse engineering and then a little more with Apple's code and a little more after that and suddenly my view of how deep I could go into code changed. So let's see if we can fix this without the source code! 5 There was a recent post I saw on HN about malleable software (attached discussion) that advocated for software that is as fully customizable as the physical world. I see reverse engineering as a way to make that happen even if the developers aren't on board.

Tracing the log

First we need to find what portion of code is injecting start process_extensions. We know it's in MacForge, but where? ripgrep is a great tool for this. After installing ripgrep 6 brew install ripgrep. Does find work? Maybe! we can run it in the MacForge.app folder 7 All Mac apps are actually just folders, and all iOS IPAs are just .zip files. and search for the string process_extensions across all files and binaries.

rg --binary "process_extensions" /Applications/MacForge.app
/Applications/MacForge.app/Contents/Library/LoginItems/MacForgeHelper.app/Contents/Resources/PluginLoader.bundle/Contents/MacOS/PluginLoader: binary file matches (found "\0" byte around offset 4)

It shows up in a single file, PluginLoader. We can open that binary in Hopper 8 The default import options worked fine, see my previous reverse engineering posts if you're looking for more details about using Hopper. and search through the strings for it. We can see that this is invoked with puts, which just writes to stdout.

This looks like a debug log that got left in the final build. But how do we get rid of it?

Patching the binary

Everyone has an abstraction layer of computing that they operate in. For the overwhelming majority of people it's at the highest layer, where code doesn't even enter the picture, and either YouTube works or it doesn't. If you've made it to this point in the blog, you're probably at least heard of a language like Python, 9 And if you haven't, then I hope at least the random asides are fun. and maybe even programmed in something like it. Some of y'all may even have poked around in some assembly, or at least know enough to look at something like movl $0, %eax and authoritatively go "uh...I think that's assembly".

Ignore the marketing speak of "AI" at the top, and remember that Math/Logic/Physics goes deep into an inverted triangle of their own.
Shamelessly stolen from a Cline blog post 10

But now it's time to dip our toes into a new abstraction layer! Computers are code, and all code is just 1s and 0s, and we can change the 1s to 0s and 0s to 1s. To do this we need to know what 1s and 0s control printing that string, and what we need to change them to to not print. Hopper allows us to do the former: we can select the puts command and look at the assembly, and it will tell us the "Instruction Encoding" off to the left that this translates to: 46 05 00 94 (though technically this isn't 1s and 0s, it's hexadecimal to make it shorter to read and easier to chunk). 11 Fine, it's 01000110 00000101 00000000 10010100. You happy? 01100111 01100101 01110100 00100000 01100001 00100000 01101100 01101001 01100110 01100101

We want to replace this with something that does nothing. In Assembly this is called a NOP, short for "no operation". Checking Wikipedia here for ARM shows that this is 0xD503201F, which becomes 1F 20 03 D5 in little endian. 12 If you have multiple bytes (chunks like 1F) you can either count them from the left to right (big endian) or right to left (little endian). The terms come from Gulliver's Travels, where the Lilliputians segregate based on whether they break the shell of a boiled egg from the big end or the little end (dating back to 1726 in the case of Gulliver's Travels, but only 1980 in this context, stemming from Cohen's funny and hyperbolically titled On Holy Wars and a Plea for Peace).

Now we need to edit the binary. Opening it in Xcode and then hitting Cmd+Shift+J and right-clicking with Open As > Hex allows you to see into the matrix. 13 I think I prefer the Woman in Red.

Searching for 46 05 00 94 finds one match, and we can easily swap it out for 1F 20 03 D5.

Before After

If we load up the modified file in Hopper we can see that the puts command has been replaced with nop. Success!

Mission accomplished

Alright, not so fast: after a restart it's still happening. It turns out that as part of MacForge's installation it copies the PluginLoader.bundle over to /Library/Application Support/MacEnhance/CorePlugins/PluginLoader.bundle, which is missing my changes. 14 Found using find ~/Library /Library -name '*PluginLoader*'. If I copy my changes over to that folder...zsh crashes when I try my minimal repro. Luckily using other apps and Console I can find the reason—codesigning.

Codesigning is a protection that ensures that code hasn't been modified (like we're doing right now)! It's okay in this case because we're intentionally doing it, but if an attacker was subtly doing this they could do a lot of damage. The solution is easy though, which is just to resign the binary to say “this code is expected”:

codesign --force --deep --sign - /Applications/MacForge.app/Contents/Library/LoginItems/MacForgeHelper.app/Contents/Resources/PluginLoader.bundle

With a quick change to /Applications/MacForge.app/Contents/Info.plist to bump the Bundle version to 4 (and codesigning that app as well) we finally have a working fixed version. 15 You can verify if a binary is correctly signed with codesign -v <path_to_binary>. I made a quick issue for searchability on the GitHub, and uploaded the fixed version to the repo. Tada!

Always remember, everything's code, and code is just 1s and 0s. Or at least, most of it is. If it's not, best to trace back up a few steps: I hear vibe coding's lovely this time of year.


  1. osascript is a command-line tool on macOS that runs AppleScript or JavaScript scripts, mainly for automation tasks. Think of it as the runner for the macOS scripting language. ↩︎

  2. Given how much of my day job is debugging I eventually want to write up something more concrete about my process, but in the meantime here's someone else's that I enjoyed↩︎

  3. I even compiled my own C and Obj-C scripts that printed out "test" with clang, but running them didn't have the prefix—there's something special about osascript (or really, a .app environment). ↩︎

  4. As the name implies, it allows you to rename Spaces. The GitHub link has more details, but it’s generally composed of two pieces—I set the space names: and they show up in the system UI (featuring some light teasers for WIP blog posts):  ↩︎

  5. There was a recent post I saw on HN about malleable software (attached discussion) that advocated for software that is as fully customizable as the physical world. I see reverse engineering as a way to make that happen even if the developers aren't on board. ↩︎

  6. brew install ripgrep. Does find work? Maybe! ↩︎

  7. All Mac apps are actually just folders, and all iOS IPAs are just .zip files↩︎

  8. The default import options worked fine, see my previous reverse engineering posts if you're looking for more details about using Hopper. ↩︎

  9. And if you haven't, then I hope at least the random asides are fun.  ↩︎

  10. Ignore the marketing speak of "AI" at the top, and remember that Math/Logic/Physics goes deep into an inverted triangle of their own↩︎

  11. Fine, it's 01000110 00000101 00000000 10010100. You happy? 01100111 01100101 01110100 00100000 01100001 00100000 01101100 01101001 01100110 01100101 ↩︎

  12. If you have multiple bytes (chunks like 1F) you can either count them from the left to right (big endian) or right to left (little endian). The terms come from Gulliver's Travels, where the Lilliputians segregate based on whether they break the shell of a boiled egg from the big end or the little end (dating back to 1726 in the case of Gulliver's Travels, but only 1980 in this context, stemming from Cohen's funny and hyperbolically titled On Holy Wars and a Plea for Peace). ↩︎

  13. I think I prefer the Woman in Red.  ↩︎

  14. Found using find ~/Library /Library -name '*PluginLoader*'↩︎

  15. You can verify if a binary is correctly signed with codesign -v <path_to_binary>↩︎