Table of contents

Context

AGEPoly is the main student representation association at EPFL, representing over 10,000 students. It manages a budget of 2M CHF, counts 500 members, and is led by a committee of 14. And I’m the General Secretary of AGEPoly, which means I handle all kinds of things.

But today, I wanted to have some fun. Our IT Manager mentioned hosting a pentest contest for AGEVote soon - the platform AGEPoly uses to manage votes during General Assemblies. The platform features complex cryptographic mechanisms, multiple service interactions, and an unusual authentication flow designed to ensure secure voting for students.

So, I decided to take a quick look at the codebase and try to understand how it works under the hood. Since there’s no GA scheduled, the service is currently offline: perfect time to dig in.

Architecture

The overall architecture is Rust (backend) + Vue (frontend) the code is available here. I’ll focus exclusively on the backend, where the business logic and all the interesting stuff lives.

The Authentication Mechanism

The first thing that stands out is the auth flow. It works like this:

  1. Enter an @epfl.ch email address.
  2. Receive an email from the service asking you to reply.
  3. Reply to the email.
  4. The backend verifies the reply, and you’re authenticated.

The key detail: it’s postal.agepoly.ch that handles inbound mail, which then forwards it to the /api/mail endpoint on vote.agepoly.ch.

But that endpoint has no authentication. Anyone can send requests to /api/mail, meaning we can try to forge requests and bypass the first layer of verification.

How the Backend Processes Verification Emails

The backend processes the raw EML directly; Postal transfers it as-is to the backend. The function responsible is:

pub async fn process_received_eml(&self, eml: String) -> Result<(), ControllerError>

It parses the email, extracting the principal fields: from, keyset_id, and keyset_hash. Then it checks that these three values correspond to what’s stored in the database.

Finally, there’s a DKIM check. This function should theoretically prevent us from forging any kind of false email:

pub async fn verify_dkim(eml: String) -> Result<bool, String> {                                                                                
    let authenticator: MessageAuthenticator = MessageAuthenticator::new_google()                                                                
        .map_err(|err| format!("unable to create message authenticator: {err:?}"))?;
    let authenticated_message = AuthenticatedMessage::parse(eml.as_bytes())                                                                     
        .ok_or("unable to parse authenticated message".to_owned())?;                                                                            
    let results = authenticator.verify_dkim(&authenticated_message).await;
    Ok(results.iter().all(|s| s.result() == &DkimResult::Pass))                                                                                 
}

It uses a Rust library to validate DKIM signatures - parsing the EML, then verifying the signatures. I didn’t know much about any of this going in, but bear with me.

The function was mostly copy-pasted from the mail-auth library documentation. But what does it actually verify?

“DomainKeys Identified Mail (DKIM) permits a person, role, or organization that owns the signing domain to claim some responsibility for a message by associating the domain with the message.” RFC 6376 It only checks that the message is associated with a domain. There is no check that the signing domain matches the sender’s domain. No check of the actual sender identity.

I could create a DKIM signature from my personal Gmail account, include it as a header using a @epfl.ch Sender and the system would consider it valid.

But it gets worse. I could submit an email with no DKIM signature at all, and it would still pass:

Ok(results.iter().all(|s| s.result() == &DkimResult::Pass))

In Rust, the .all() method returns true on an empty iterator and verify_dkim returns an empty vector if there are no signatures. So, the function would return true if there are no signatures at all, meaning that any email would be considered valid.

Implications

So, I could register as [email protected] and gain verified access. While still limited by other measures like physical presence checks, it’s verified nonetheless. I could also impersonate admin or operator emails, which are visible on the frontend during a GA. That would be enough to disrupt an entire election (DoS).

But I won’t, and I will disclose it now :)