Table of contents
TL;DR A newbie found a high severity heap buffer overflow in Android Open Source Project and pushed a patch.
Context
My goal in this post is to inspire young students to make them know that even if they have low experience in cybersecurity they can find highly rewarded vulnerabities with almost no prior experience. I’m currently a last year master student in Cybersecurity. My experience in vuln. research and practical cybersecurity is quite low, despite training for CTFs, and having a solid theoretical background.
The Discovery
On a random day in January 2026, I was at my desk working and was just about to go to a meeting for my student association. I connected my AirPods via Bluetooth (as always) to my Pixel 8 Pro, running GrapheneOS.
How and why GrapheneOS
“GrapheneOS is a private and secure mobile operating system with great functionality and usability.”
I decided to install it for personal security and privacy reasons, and out of genuine curiosity about a hardened Android OS. It especially helped me discover the bug using the user-facing crash reporting feature.
DAMN, I just hit a crash trying to connect my AirPods and a report is showing up on my phone. I don’t really pay attention to it since it appears often in some apps I use, so as usual I store it in my notes, re-connect to my AirPods, and it works. I head to my meeting.
Some days later, I wonder what really happened. Checking the logs, it’s appearing directly in Android which is really unusual, so I decide to investigate.
The logs
type: crash
osVersion: google/husky/husky:16/BP4A.260105.004.E1/2026012801:user/release-keys
uid: 1041 (u:r:audioserver:s0)
cmdline: /system/bin/audioserver
processUptime: 0s
signal: 11 (SIGSEGV), code 8 (SEGV_MTEAERR)
threadName: AudioOut_15
MTE: enabled
backtrace:
/apex/com.android.runtime/lib64/bionic/libc.so (syscall+32, pc a8b60)
/system/lib64/libfmq.so (android::hardware::EventFlag::wake(unsigned int)+68, pc 4174)
/system/lib64/[email protected] (android::MessageQueueBase<android::BackendTypesStore::AidlMQDescriptorShimType, aidl::android::hardware::audio::core::StreamDescriptor::Command, (android::hardware::MQFlavor)1>::writeBlocking(aidl::android::hardware::audio::core::StreamDescriptor::Command const\*, unsigned long, unsigned int, unsigned int, long, android::hardware::EventFlag\*)+580, pc 1627c4)
/system/lib64/[email protected] (android::StreamHalAidl::sendCommand(aidl::android::hardware::audio::core::StreamDescriptor::Command const&, aidl::android::hardware::audio::core::StreamDescriptor::Reply\*, bool, android::StreamHalAidl::StatePositions\*)+268, pc 161aac)
/system/lib64/[email protected] (android::StreamHalAidl::transfer(void\*, unsigned long, unsigned long\*)+516, pc 1661b4)
/system/lib64/libnbaio.so (android::AudioStreamOutSink::write(void const\*, unsigned long)+124, pc afac)
/system/bin/audioserver (android::PlaybackThread::threadLoop_write()+740, pc 185ce4)
/system/bin/audioserver (android::MixerThread::threadLoop_write()+536, pc 1964b8)
/system/bin/audioserver (android::PlaybackThread::threadLoop()+10812, pc 18c02c)
/system/lib64/libutils.so (android::Thread::_threadLoop(void\*)+208, pc 18250)
/system/lib64/libutils.so (libutil_thread_trampoline(void\*) (.__uniq.226528677032898775202282855395389835431)+24, pc 1a5b8)
/apex/com.android.runtime/lib64/bionic/libc.so (__pthread_start(void\*) (.__uniq.67847048707805468364044055584648682506)+180, pc 91584)
/apex/com.android.runtime/lib64/bionic/libc.so (__start_thread+68, pc 813d4)
"
The first thing to check was the error code: SEGV_MTEAERR. SIGSEGV means this is a memory corruption error. MTE refers to the Memory Tagging Extension, a hardware feature that helps detect memory corruptions and that is one of the feature GrapheneOS greatly capitalize on for security enhancements. Specifically: MTEAERR means asynchronous. These faults are not reported immediately at the time of corruption, but at the next syscall, interrupt, or context switch. So the current trace is just a hint about where the error came from, not the exact point.
Looking at the trace, it clearly involves audio libraries and audioserver, which made sense given my AirPods connection. It involves thread loops and thread writes pointing to a possible race condition, where at a certain timing something accesses or writes to a pointer whose MTE tag no longer matches.
Interestingly, the process uptime is 0s, meaning the crash happened very early during initialization of the audioserver. This further validates the AirPods connection theory and leads my research toward the audioserver initialization process.
I downloaded the relevant part of this library locally to begin the hunt, and checked every function in the trace from the bottom up until something made sense, and at the beginning nothing made sense.
So to get more context I used Claude he roughly explained the place and thing I was searching for. I was completely lost. I made him search some candidates but he kept returning weird results that for me didn’t make sense.
The trace led me to a specific file: Threads.cpp, and more specifically to PlaybackThread::threadLoop_write() and readOutputParameters_l().
readOutputParameters_l() is called at initialization of PlaybackThread, or during reconfiguration of the audio output stream in MixerThread and DirectOutputThread making it a perfect candidate for review, consistent with the AirPods reconfiguration trigger.
ReadOutputParameters_l Function
void PlaybackThread::readOutputParameters_l()
NO_THREAD_SAFETY_ANALYSIS
// 'moveEffectChain_ll' requires holding mutex 'AudioFlinger_Mutex' exclusively
{
// unfortunately we have no way of recovering from errors here, hence the LOG_ALWAYS_FATAL
const audio_config_base_t audioConfig = mOutput->getAudioProperties();
mSampleRate = audioConfig.sample_rate;
mChannelMask = audioConfig.channel_mask;
if (!audio_is_output_channel(mChannelMask)) {
LOG_ALWAYS_FATAL("HAL channel mask %#x not valid for output", mChannelMask);
}
if (hasMixer() && !isValidPcmSinkChannelMask(mChannelMask)) {
LOG_ALWAYS_FATAL("HAL channel mask %#x not supported for mixed output",
mChannelMask);
}
if (mMixerChannelMask == AUDIO_CHANNEL_NONE) {
mMixerChannelMask = mChannelMask;
}
mChannelCount = audio_channel_count_from_out_mask(mChannelMask);
mBalance.setChannelMask(mChannelMask);
uint32_t mixerChannelCount = audio_channel_count_from_out_mask(mMixerChannelMask);
// Get actual HAL format.
status_t result = mOutput->stream->getAudioProperties(nullptr, nullptr, &mHALFormat);
LOG_ALWAYS_FATAL_IF(result != OK, "Error when retrieving output stream format: %d", result);
// Get format from the shim, which will be different than the HAL format
// if playing compressed audio over HDMI passthrough.
mFormat = audioConfig.format;
if (!audio_is_valid_format(mFormat)) {
LOG_ALWAYS_FATAL("HAL format %#x not valid for output", mFormat);
}
if (hasMixer() && !isValidPcmSinkFormat(mFormat)) {
LOG_FATAL("HAL format %#x not supported for mixed output",
mFormat);
}
mFrameSize = mOutput->getFrameSize();
result = mOutput->stream->getBufferSize(&mBufferSize);
LOG_ALWAYS_FATAL_IF(result != OK,
"Error when retrieving output stream buffer size: %d", result);
mFrameCount = mBufferSize / mFrameSize;
if (hasMixer() && (mFrameCount & 15)) {
ALOGW("HAL output buffer size is %zu frames but AudioMixer requires multiples of 16 frames",
mFrameCount);
}
mHwSupportsPause = false;
if (mOutput->flags & AUDIO_OUTPUT_FLAG_DIRECT) {
bool supportsPause = false, supportsResume = false;
if (mOutput->stream->supportsPauseAndResume(&supportsPause, &supportsResume) == OK) {
if (supportsPause && supportsResume) {
mHwSupportsPause = true;
} else if (supportsPause) {
ALOGW("direct output implements pause but not resume");
} else if (supportsResume) {
ALOGW("direct output implements resume but not pause");
}
}
}
if (!mHwSupportsPause && mOutput->flags & AUDIO_OUTPUT_FLAG_HW_AV_SYNC) {
LOG_ALWAYS_FATAL("HW_AV_SYNC requested but HAL does not implement pause and resume");
}
if (mType == DUPLICATING && mMixerBufferEnabled && mEffectBufferEnabled) {
// For best precision, we use float instead of the associated output
// device format (typically PCM 16 bit).
mFormat = AUDIO_FORMAT_PCM_FLOAT;
mFrameSize = mChannelCount * audio_bytes_per_sample(mFormat);
mBufferSize = mFrameSize * mFrameCount;
// TODO: We currently use the associated output device channel mask and sample rate.
// (1) Perhaps use the ORed channel mask of all downstream MixerThreads
// (if a valid mask) to avoid premature downmix.
// (2) Perhaps use the maximum sample rate of all downstream MixerThreads
// instead of the output device sample rate to avoid loss of high frequency information.
// This may need to be updated as MixerThread/OutputTracks are added and not here.
}
// Calculate size of normal sink buffer relative to the HAL output buffer size
double multiplier = 1.0;
// Note: mType == SPATIALIZER does not support FastMixer and DEEP is by definition not "fast"
if ((mType == MIXER && !(mOutput->flags & AUDIO_OUTPUT_FLAG_DEEP_BUFFER)) &&
(kUseFastMixer == FastMixer_Static || kUseFastMixer == FastMixer_Dynamic)) {
size_t minNormalFrameCount = (kMinNormalSinkBufferSizeMs * mSampleRate) / 1000;
size_t maxNormalFrameCount = (kMaxNormalSinkBufferSizeMs * mSampleRate) / 1000;
// round up minimum and round down maximum to nearest 16 frames to satisfy AudioMixer
minNormalFrameCount = (minNormalFrameCount + 15) & ~15;
maxNormalFrameCount = maxNormalFrameCount & ~15;
if (maxNormalFrameCount < minNormalFrameCount) {
maxNormalFrameCount = minNormalFrameCount;
}
multiplier = (double) minNormalFrameCount / (double) mFrameCount;
if (multiplier <= 1.0) {
multiplier = 1.0;
} else if (multiplier <= 2.0) {
if (2 * mFrameCount <= maxNormalFrameCount) {
multiplier = 2.0;
} else {
multiplier = (double) maxNormalFrameCount / (double) mFrameCount;
}
} else {
multiplier = floor(multiplier);
}
}
mNormalFrameCount = multiplier * mFrameCount;
// round up to nearest 16 frames to satisfy AudioMixer
if (hasMixer()) {
mNormalFrameCount = (mNormalFrameCount + 15) & ~15;
}
ALOGI("HAL output buffer size %zu frames, normal sink buffer size %zu frames",
(size_t)mFrameCount, mNormalFrameCount);
// Check if we want to throttle the processing to no more than 2x normal rate
mThreadThrottle = property_get_bool("af.thread.throttle", true /* default_value */);
mThreadThrottleTimeMs = 0;
mThreadThrottleEndMs = 0;
mHalfBufferMs = mNormalFrameCount * 1000 / (2 * mSampleRate);
// mSinkBuffer is the sink buffer. Size is always multiple-of-16 frames.
// Originally this was int16_t[] array, need to remove legacy implications.
free(mSinkBuffer);
mSinkBuffer = NULL;
// For sink buffer size, we use the frame size from the downstream sink to avoid problems
// with non PCM formats for compressed music, e.g. AAC, and Offload threads.
const size_t sinkBufferSize = mNormalFrameCount * mFrameSize;
(void)posix_memalign(&mSinkBuffer, 32, sinkBufferSize);
// We resize the mMixerBuffer according to the requirements of the sink buffer which
// drives the output.
free(mMixerBuffer);
mMixerBuffer = NULL;
if (mMixerBufferEnabled) {
mMixerBufferFormat = AUDIO_FORMAT_PCM_FLOAT; // no longer valid: AUDIO_FORMAT_PCM_16_BIT.
mMixerBufferSize = mNormalFrameCount * mixerChannelCount
* audio_bytes_per_sample(mMixerBufferFormat);
(void)posix_memalign(&mMixerBuffer, 32, mMixerBufferSize);
}
free(mEffectBuffer);
mEffectBuffer = NULL;
if (mEffectBufferEnabled) {
mEffectBufferFormat = AUDIO_FORMAT_PCM_FLOAT;
mEffectBufferSize = mNormalFrameCount * mixerChannelCount
* audio_bytes_per_sample(mEffectBufferFormat);
(void)posix_memalign(&mEffectBuffer, 32, mEffectBufferSize);
}
if (mType == SPATIALIZER) {
free(mPostSpatializerBuffer);
mPostSpatializerBuffer = nullptr;
mPostSpatializerBufferSize = mNormalFrameCount * mChannelCount
* audio_bytes_per_sample(mEffectBufferFormat);
(void)posix_memalign(&mPostSpatializerBuffer, 32, mPostSpatializerBufferSize);
}
mHapticChannelMask = static_cast<audio_channel_mask_t>(mChannelMask & AUDIO_CHANNEL_HAPTIC_ALL);
mChannelMask = static_cast<audio_channel_mask_t>(mChannelMask & ~mHapticChannelMask);
mHapticChannelCount = audio_channel_count_from_out_mask(mHapticChannelMask);
mChannelCount -= mHapticChannelCount;
mMixerChannelMask = static_cast<audio_channel_mask_t>(mMixerChannelMask & ~mHapticChannelMask);
// force reconfiguration of effect chains and engines to take new buffer size and audio
// parameters into account
// Note that mutex() is not held when readOutputParameters_l() is called from the constructor
// but in this case nothing is done below as no audio sessions have effect yet so it doesn't
// matter.
// create a copy of mEffectChains as calling moveEffectChain_ll()
// can reorder some effect chains
Vector<sp<IAfEffectChain>> effectChains = mEffectChains;
for (size_t i = 0; i < effectChains.size(); i ++) {
mAfThreadCallback->moveEffectChain_ll(effectChains[i]->sessionId(),
this/* srcThread */, this/* dstThread */);
}
audio_output_flags_t flags = mOutput->flags;
mediametrics::LogItem item(mThreadMetrics.getMetricsId()); // TODO: method in ThreadMetrics?
item.set(AMEDIAMETRICS_PROP_EVENT, AMEDIAMETRICS_PROP_EVENT_VALUE_READPARAMETERS)
.set(AMEDIAMETRICS_PROP_ENCODING, IAfThreadBase::formatToString(mFormat).c_str())
.set(AMEDIAMETRICS_PROP_SAMPLERATE, (int32_t)mSampleRate)
.set(AMEDIAMETRICS_PROP_CHANNELMASK, (int32_t)mChannelMask)
.set(AMEDIAMETRICS_PROP_CHANNELCOUNT, (int32_t)mChannelCount)
.set(AMEDIAMETRICS_PROP_FRAMECOUNT, (int32_t)mNormalFrameCount)
.set(AMEDIAMETRICS_PROP_FLAGS, toString(flags).c_str())
.set(AMEDIAMETRICS_PROP_PREFIX_HAPTIC AMEDIAMETRICS_PROP_CHANNELMASK,
(int32_t)mHapticChannelMask)
.set(AMEDIAMETRICS_PROP_PREFIX_HAPTIC AMEDIAMETRICS_PROP_CHANNELCOUNT,
(int32_t)mHapticChannelCount)
.set(AMEDIAMETRICS_PROP_PREFIX_HAL AMEDIAMETRICS_PROP_ENCODING,
IAfThreadBase::formatToString(mHALFormat).c_str())
.set(AMEDIAMETRICS_PROP_PREFIX_HAL AMEDIAMETRICS_PROP_FRAMECOUNT,
(int32_t)mFrameCount) // sic - added HAL
;
uint32_t latencyMs;
if (mOutput->stream->getLatency(&latencyMs) == NO_ERROR) {
item.set(AMEDIAMETRICS_PROP_PREFIX_HAL AMEDIAMETRICS_PROP_LATENCYMS, (double)latencyMs);
}
item.record();
}Here we will focus on the free(mSinkBuffer) which is one possible root cause of a memory corruption.
This buffer is linked to the mBytesWritten, mBytesRemaining, mCurrentWriteLength variables, which are used in the threadLoop_write() function, which is suspected to have played a role in the crash.
ThreadLoop_write Function
// shared by MIXER and DIRECT, overridden by DUPLICATING
ssize_t PlaybackThread::threadLoop_write()
{
LOG_HIST_TS();
mInWrite = true;
ssize_t bytesWritten;
const size_t offset = mCurrentWriteLength - mBytesRemaining;
// If an NBAIO sink is present, use it to write the normal mixer's submix
if (mNormalSink != 0) {
const size_t count = mBytesRemaining / mFrameSize;
ATRACE_BEGIN("write");
// update the setpoint when AudioFlinger::mScreenState changes
const uint32_t screenState = mAfThreadCallback->getScreenState();
if (screenState != mScreenState) {
mScreenState = screenState;
MonoPipe *pipe = (MonoPipe *)mPipeSink.get();
if (pipe != NULL) {
pipe->setAvgFrames((mScreenState & 1) ?
(pipe->maxFrames() * 7) / 8 : mNormalFrameCount * 2);
}
}
ssize_t framesWritten = mNormalSink->write((char *)mSinkBuffer + offset, count);
ATRACE_END();
if (framesWritten > 0) {
bytesWritten = framesWritten * mFrameSize;
#ifdef TEE_SINK
mTee.write((char *)mSinkBuffer + offset, framesWritten);
#endif
} else {
bytesWritten = framesWritten;
}
// otherwise use the HAL / AudioStreamOut directly
} else {
// Direct output and offload threads
if (mUseAsyncWrite) {
ALOGW_IF(mWriteAckSequence & 1, "threadLoop_write(): out of sequence write request");
mWriteAckSequence += 2;
mWriteAckSequence |= 1;
ALOG_ASSERT(mCallbackThread != 0);
mCallbackThread->setWriteBlocked(mWriteAckSequence);
}
ATRACE_BEGIN("write");
// FIXME We should have an implementation of timestamps for direct output threads.
// They are used e.g for multichannel PCM playback over HDMI.
bytesWritten = mOutput->write((char *)mSinkBuffer + offset, mBytesRemaining);
ATRACE_END();
if (mUseAsyncWrite &&
((bytesWritten < 0) || (bytesWritten == (ssize_t)mBytesRemaining))) {
// do not wait for async callback in case of error of full write
mWriteAckSequence &= ~1;
ALOG_ASSERT(mCallbackThread != 0);
mCallbackThread->setWriteBlocked(mWriteAckSequence);
}
}
mNumWrites++;
mInWrite = false;
if (mStandby) {
mThreadMetrics.logBeginInterval();
mThreadSnapshot.onBegin();
mStandby = false;
}
return bytesWritten;
}
AudioThread running continuously. This thread loops forever:
FOREVER:
1. Lock mutex
- check for config changes
- prepare tracks
- decide what to mix
2. Unlock mutex
3. Mix audio into mSinkBuffer ← NO LOCK
4. Apply effects ← NO LOCK
5. Write mSinkBuffer to HAL ← NO LOCK
6. Repeat
Now imagine the thread is writing to the audio output stream, but the HAL doesn’t accept all of mSinkBuffer in one call meaning it’s a partial write. mBytesRemaining is decremented by what was actually written, leaving a non-zero remainder for the next iteration.
But at that same moment, an audio reconfiguration happens because of a Bluetooth connection. This calls readOutputParameters_l(), which frees mSinkBuffer and reallocates it. But, mBytesRemaining and mCurrentWriteLength are not reset, so they keep their stale values from the previous iteration.
On the next iteration of threadLoop_write(), the condition if (mBytesRemaining == 0) is false, so threadLoop_mix() is skipped entirely and the new buffer is never filled. The write then proceeds using the stale offset and count against the freshly allocated buffer.
const size_t offset = mCurrentWriteLength - mBytesRemaining;
const size_t count = mBytesRemaining / mFrameSize;
ssize_t framesWritten = mNormalSink->write((char *)mSinkBuffer + offset, count);
This alone is already wrong, it sends uninitialized memory to the HAL. But it gets worse.
For an actual out-of-bounds write, we need the new mSinkBuffer to be smaller than mCurrentWriteLength. If that’s the case, mSinkBuffer + offset starts the write past the end of the new allocation, directly into the adjacent heap chunk.
For example:
Previous iteration:
mCurrentWriteLength = 8192 (stereo 48kHz buffer)
HAL accepts 4096 bytes → mBytesRemaining = 4096
readOutputParameters_l() reallocates mSinkBuffer to 4096 bytes
mBytesRemaining and mCurrentWriteLength NOT reset
Next iteration:
offset = 8192 - 4096 = 4096
mSinkBuffer size = 4096 bytes
write(mSinkBuffer + 4096, count)
↑ starts exactly at the end of the new buffer
↑ writes into the adjacent heap chunk → out-of-bounds
Since sinkBufferSize = mNormalFrameCount * mFrameSize, and mNormalFrameCount depends on mSampleRate, the buffer size does change when the sample rate changes.
On MTE-enabled devices like the Pixel 8 Pro, the adjacent heap chunk carries a different MTE tag than the pointer. The hardware detects the mismatch and raises SEGV_MTEAERR asynchronously which is precisely what the tombstone shows, surfacing at the next syscall in EventFlag::wake().
The Fix
The fix is straightforward: reset mBytesRemaining and mCurrentWriteLength to 0 inside readOutputParameters_l() after reallocating mSinkBuffer. This forces the next iteration into the mix block, which correctly fills the new buffer and sets mCurrentWriteLength to the new mSinkBufferSize before any write occurs.
free(mSinkBuffer);
mSinkBuffer = NULL;
mBytesRemaining = 0; // reset stale write metadata
mCurrentWriteLength = 0;
const size_t sinkBufferSize = mNormalFrameCount * mFrameSize;
(void)posix_memalign(&mSinkBuffer, 32, sinkBufferSize);
The patch was submitted to AOSP and accepted: CL 3939698. I then submitted a report for a bounty, I didn’t have time honestly for the report, I just submitted tombstone and patch and was never able to reproduce the crash.
Timeline
| Date | Event |
|---|---|
| January 2026 | Crash observed while connecting AirPods |
| Shortly after | Root cause identified in Threads.cpp |
| February 2026 | Patch submitted and accepted to AOSP |
| February 2026 | Submitted report after the patch was accepted |
| May 29th 2026 | Awarded a 4'000$ bounty for low quality report, tombstone and patch |
The severity
I tried to understand the severity of what I just found and without reproducibility it was complicated. I should have controlled the sample rate and reconfiguration timing using a local unprivileged app (or remotely) but that seemed complicated and I wasn’t able to reproduce it through heavy adb fuzzing. Regarding the audio server process, it is an important one, centralizing all the audio services, but yeah still no proof of compromission.
What I Learned
Finding and fixing a bug are two different things, vulnerability research is a very long process, and maybe without AI I would have abandonned this. I think hardware-backed technologies such as PAC, MTE, are great features.